Skip to content
Merged
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions examples/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
- [Snippets](./snippets)
- [Contexts](./contexts)
- [Binds](./binds)
- [Wrappers](./wrappers)
- [Deprecated Svelte 3 and 4 features](./deprecated)
14 changes: 14 additions & 0 deletions examples/wrappers/child.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<script>
import { getContext } from 'svelte'

let { label } = $props()

const messages = getContext('messages')
</script>

<div role="status" aria-label={label}>
{#each messages.current as message (message.id)}
<p>{message.text}</p>
<hr />
{/each}
</div>
22 changes: 22 additions & 0 deletions examples/wrappers/child.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/svelte'
import { expect, test } from 'vitest'

import Subject from './child.svelte'
import Wrapper from './wrapper.svelte'

test('notifications with messages from context', () => {
const messages = [
{ id: 'abc', text: 'hello' },
{ id: 'def', text: 'world' },
]

render(
Subject,
{ label: 'Notifications' },
{ wrapper: Wrapper, wrapperProps: { messages } }
)

const status = screen.getByRole('status', { name: 'Notifications' })

expect(status).toHaveTextContent('hello world')
})
87 changes: 87 additions & 0 deletions examples/wrappers/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Wrappers

Sometimes, a component cannot properly render or operate unless it's the child
of another component. To wrap your component under test in a parent, use the
`wrapper` and `wrapperProps` options.

> \[!TIP]
>
> If you can't test a component in isolation, this may be a sign that you're
> testing at the wrong level, or that your component structure should be
> rethought. Consider if you can make your components more testable in isolation
> before reaching for this option.

While this example uses context to demonstrate the usage of the `wrapper`
options, if you have access to the context directly, not through a provider
component, then the [`context` option](../contexts) is a better choice than
`wrapper`.

## Table of contents

- [`wrapper.svelte`](#wrappersvelte)
- [`child.svelte`](#childsvelte)
- [`child.test.js`](#childtestjs)

## `wrapper.svelte`

```svelte file=./wrapper.svelte
<script>
import { setContext } from 'svelte'

let { messages, children } = $props()

setContext('messages', {
get current() {
return messages
},
})
</script>

{@render children?.()}
```

## `child.svelte`

```svelte file=./child.svelte
<script>
import { getContext } from 'svelte'

let { label } = $props()

const messages = getContext('messages')
</script>

<div role="status" aria-label={label}>
{#each messages.current as message (message.id)}
<p>{message.text}</p>
<hr />
{/each}
</div>
```

## `child.test.js`

```js file=./child.test.js
import { render, screen } from '@testing-library/svelte'
import { expect, test } from 'vitest'

import Subject from './child.svelte'
import Wrapper from './wrapper.svelte'

test('notifications with messages from context', () => {
const messages = [
{ id: 'abc', text: 'hello' },
{ id: 'def', text: 'world' },
]

render(
Subject,
{ label: 'Notifications' },
{ wrapper: Wrapper, wrapperProps: { messages } }
)

const status = screen.getByRole('status', { name: 'Notifications' })

expect(status).toHaveTextContent('hello world')
})
```
13 changes: 13 additions & 0 deletions examples/wrappers/wrapper.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script>
import { setContext } from 'svelte'

let { messages, children } = $props()

setContext('messages', {
get current() {
return messages
},
})
</script>

{@render children?.()}
71 changes: 45 additions & 26 deletions packages/svelte-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,19 +68,20 @@ const { baseElement, container, component, rerender, unmount } = render(
)
```

| Argument | Type | Description |
| ------------------ | ------------------------------------------------------- | --------------------------------------------- |
| `Component` | [Svelte component][svelte-component-docs] | An imported Svelte component |
| `componentOptions` | `Props` or partial [`mount` options][svelte-mount-docs] | Options for how the component will be mounted |
| `setupOptions` | `{ baseElement?: HTMLElement }` | Optionally override `baseElement` |

| Result | Type | Description | Default |
| ------------- | ------------------------------------------ | ---------------------------------------- | ----------------------------------- |
| `baseElement` | `HTMLElement` | The base element | `document.body` |
| `container` | `HTMLElement` | The component's immediate parent element | `<div>` appended to `document.body` |
| `component` | [component exports][svelte-mount-docs] | The component's exports from `mount` | N/A |
| `rerender` | `(props: Partial<Props>) => Promise<void>` | Update the component's props | N/A |
| `unmount` | `() => void` | Unmount the component from the document | N/A |
| Argument | Type | Description |
| ------------------ | ------------------------------------------------------- | ------------------------------------------------------- |
| `Component` | [Svelte component][svelte-component-docs] | An imported Svelte component |
| `componentOptions` | `Props` or partial [`mount` options][svelte-mount-docs] | Options for how the component will be mounted |
| `setupOptions` | [`SetupOptions`](#setup) | Optionally override `baseElement` or wrap the component |

| Result | Type | Description | Default |
| ------------- | ------------------------------------------ | -------------------------------------------------- | ----------------------------------- |
| `baseElement` | `HTMLElement` | The base element | `document.body` |
| `container` | `HTMLElement` | The component's immediate parent element | `<div>` appended to `document.body` |
| `component` | [component exports][svelte-mount-docs] | The component's exports from `mount` | N/A |
| `wrapper` | [component exports][svelte-mount-docs] | The wrapper's exports, if a `wrapper` was provided | `undefined` |
| `rerender` | `(props: Partial<Props>) => Promise<void>` | Update the component's props | N/A |
| `unmount` | `() => void` | Unmount the component from the document | N/A |

> \[!TIP]
> Calling `render` is equivalent to calling [`setup`](#setup) followed by [`mount`](#mount)
Expand All @@ -90,7 +91,11 @@ const { baseElement, container, component, rerender, unmount } = render(
> componentOptions,
> setupOptions
> )
> const { component, rerender, unmount } = mount(Component, mountOptions)
> const { component, rerender, unmount } = mount(
> Component,
> mountOptions,
> setupOptions
> )
> ```

[svelte-component-docs]: https://svelte.dev/docs/svelte-components
Expand All @@ -110,7 +115,15 @@ const { baseElement, container, mountOptions } = setup(
| Argument | Type | Description |
| ------------------ | ------------------------------------------------------- | --------------------------------------------- |
| `componentOptions` | `Props` or partial [`mount` options][svelte-mount-docs] | Options for how the component will be mounted |
| `setupOptions` | `{ baseElement?: HTMLElement }` | Optionally override `baseElement` |
| `setupOptions` | `SetupOptions` | Configure the document and wrap the component |

`setupOptions` accepts the following fields, all optional:

| Field | Type | Description | Default |
| -------------- | ----------------------------------------- | --------------------------------------------------------------------- | --------------- |
| `baseElement` | `HTMLElement` | The base element to bind queries to | `document.body` |
| `wrapper` | [Svelte component][svelte-component-docs] | A component to wrap the component under test, e.g. a context provider | `undefined` |
| `wrapperProps` | `Props` | Props to pass to the `wrapper` component | `undefined` |

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

```ts
const { component, unmount, rerender } = mount(Component, mountOptions)
const { component, wrapper, unmount, rerender } = mount(
Component,
mountOptions,
setupOptions
)
```

| Argument | Type | Description |
| -------------- | ----------------------------------------- | -------------------------------------------- |
| `Component` | [Svelte component][svelte-component-docs] | An imported Svelte component |
| `mountOptions` | [component options][svelte-mount-docs] | Options to pass to Svelte's `mount` function |

| Result | Type | Description |
| ----------- | ------------------------------------------ | --------------------------------------- |
| `component` | [component exports][svelte-mount-docs] | The component's exports from `mount` |
| `unmount` | `() => void` | Unmount the component from the document |
| `rerender` | `(props: Partial<Props>) => Promise<void>` | Update the component's props |
| Argument | Type | Description |
| -------------- | ----------------------------------------- | --------------------------------------------------------- |
| `Component` | [Svelte component][svelte-component-docs] | An imported Svelte component |
| `mountOptions` | [component options][svelte-mount-docs] | Options to pass to Svelte's `mount` function |
| `setupOptions` | [`SetupOptions`](#setup) | Optionally wrap the component (`wrapper`, `wrapperProps`) |

| Result | Type | Description |
| ----------- | ------------------------------------------ | -------------------------------------------------- |
| `component` | [component exports][svelte-mount-docs] | The component's exports from `mount` |
| `wrapper` | [component exports][svelte-mount-docs] | The wrapper's exports, if a `wrapper` was provided |
| `unmount` | `() => void` | Unmount the component from the document |
| `rerender` | `(props: Partial<Props>) => Promise<void>` | Update the component's props |

### `cleanup`

Expand Down
75 changes: 71 additions & 4 deletions packages/svelte-core/src/mount.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as Svelte from 'svelte'
import { addCleanupTask, removeCleanupTask } from './cleanup.js'
import { createProps } from './props.svelte.js'
import { IS_MODERN_SVELTE } from './svelte-version.js'
import WrapperScaffold from './wrapper-scaffold.svelte'

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

/**
* Extract a component from an import.
*
* Allows a dynamic `import('component.svelte')` to be used
* for component values.
*
* @template {import('../types.js').Component} C
* @param {import('../types.js').ComponentImport<C>} componentImport
* @returns {import('../types.js').ComponentType<C>}
*/
const unwrapComponentImport = (componentImport) => {
return 'default' in componentImport
? componentImport.default
: componentImport
}

/**
* Render a Svelte component into the document.
*
* @template {import('../types.js').Component} C
* @template {import('../types.js').Component} [W=never]
*
* @param {import('../types.js').ComponentImport<C>} Component
* @param {import('../types.js').MountOptions<C>} mountOptions
* @param {import('../types.js').SetupOptions<W>} [setupOptions]
* @returns {{componentToMount: import('../types.js').ComponentType<C | W>, mountOptions: import('../types.js').MountOptions<C | W>, isWrapper: boolean}}
*/
const setupComponent = (Component, mountOptions, setupOptions = {}) => {
const componentToMount = unwrapComponentImport(Component)
const { wrapper, wrapperProps } = setupOptions

if (wrapper) {
return {
isWrapper: true,
componentToMount: WrapperScaffold,
mountOptions: {
...mountOptions,
props: {
wrapper: unwrapComponentImport(wrapper),
wrapperProps,
component: componentToMount,
componentProps: mountOptions.props,
},
},
}
}

return { isWrapper: false, componentToMount, mountOptions }
}

/**
* Render a Svelte component into the document.
*
* @template {import('../types.js').Component} C
* @template {import('../types.js').Component} [W=never]
*
* @param {import('../types.js').ComponentImport<C>} Component
* @param {import('../types.js').MountOptions<C>} options
* @param {import('../types.js').SetupOptions<W>} [setupOptions]
* @returns {import('../types.js').MountResult<C>}
*/
const mount = (Component, options) => {
const mount = (Component, options, setupOptions = {}) => {
const { componentToMount, mountOptions, isWrapper } = setupComponent(
Component,
options,
setupOptions
)

const { component, unmount, rerender } = mountComponent(
'default' in Component ? Component.default : Component,
options
componentToMount,
mountOptions
)

return {
component,
component: isWrapper ? component.getComponent() : component,
wrapper: isWrapper ? component.getWrapper() : undefined,
unmount,
rerender: async (props) => {
if ('props' in props) {
Expand All @@ -98,6 +159,12 @@ const mount = (Component, options) => {
props = props.props
}

if (isWrapper) {
props = {
componentProps: { ...component.getComponentProps(), ...props },
}
}

rerender(props)
// Await the next tick for Svelte 3/4, which cannot flush changes synchronously
await Svelte.tick()
Expand Down
7 changes: 4 additions & 3 deletions packages/svelte-core/src/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ import { setup } from './setup.js'
* Render a component into the document.
*
* @template {import('../types.js').Component} C
* @template {import('../types.js').Component} [W=never]
*
* @param {import('../types.js').ComponentImport<C>} Component - The component to render.
* @param {import('../types.js').ComponentOptions<C>} componentOptions - Customize how Svelte renders the component.
* @param {import('../types.js').SetupOptions} setupOptions - Customize how the document is set up.
* @returns {import('../types.js').RenderResult<C>} The rendered component.
* @param {import('../types.js').SetupOptions<W>} setupOptions - Customize how the document and component is set up.
* @returns {import('../types.js').RenderResult<C, W>} The rendered component.
*/
const render = (Component, componentOptions, setupOptions = {}) => {
const { mountOptions, ...setupResult } = setup(componentOptions, setupOptions)
const mountResult = mount(Component, mountOptions)
const mountResult = mount(Component, mountOptions, setupOptions)

return { ...setupResult, ...mountResult }
}
Expand Down
3 changes: 2 additions & 1 deletion packages/svelte-core/src/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ const validateOptions = (options) => {
* Set up the document to render a component.
*
* @template {import('../types.js').Component} C
*
* @param {import('../types.js').ComponentOptions<C>} componentOptions - props or mount options
* @param {import('../types.js').SetupOptions} setupOptions - base element of the document to bind any queries
* @param {import('../types.js').SetupOptions<any>} setupOptions - base element of the document to bind any queries
* @returns {import('../types.js').SetupResult<C>}
*/
const setup = (componentOptions, setupOptions = {}) => {
Expand Down
Loading