From d2548fa4fb7df1b248718b094bf56cad1d1a8ee3 Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Sat, 20 Jun 2026 13:06:39 -0400 Subject: [PATCH 1/2] feat: add wrapper option --- examples/readme.md | 1 + examples/wrappers/child.svelte | 14 +++ examples/wrappers/child.test.js | 22 +++++ examples/wrappers/readme.md | 87 +++++++++++++++++++ examples/wrappers/wrapper.svelte | 13 +++ packages/svelte-core/README.md | 71 +++++++++------ packages/svelte-core/src/mount.js | 75 +++++++++++++++- packages/svelte-core/src/render.js | 7 +- packages/svelte-core/src/setup.js | 6 +- .../svelte-core/src/wrapper-scaffold.svelte | 21 +++++ packages/svelte-core/types.d.ts | 19 +++- packages/svelte/src/pure.js | 18 ++-- tests/_env.js | 4 + tests/fixtures/Wrapped.svelte | 12 +++ tests/fixtures/Wrapper.svelte | 15 ++++ tests/fixtures/WrapperRunes.svelte | 17 ++++ tests/render-runes.test-d.ts | 23 ++++- tests/render.test-d.ts | 21 +++++ tests/tsconfig.legacy.json | 3 +- tests/wrapper.test.js | 80 +++++++++++++++++ 20 files changed, 480 insertions(+), 49 deletions(-) create mode 100644 examples/wrappers/child.svelte create mode 100644 examples/wrappers/child.test.js create mode 100644 examples/wrappers/readme.md create mode 100644 examples/wrappers/wrapper.svelte create mode 100644 packages/svelte-core/src/wrapper-scaffold.svelte create mode 100644 tests/fixtures/Wrapped.svelte create mode 100644 tests/fixtures/Wrapper.svelte create mode 100644 tests/fixtures/WrapperRunes.svelte create mode 100644 tests/wrapper.test.js diff --git a/examples/readme.md b/examples/readme.md index e8c7e26..e477c45 100644 --- a/examples/readme.md +++ b/examples/readme.md @@ -5,4 +5,5 @@ - [Snippets](./snippets) - [Contexts](./contexts) - [Binds](./binds) +- [Wrappers]('./wrappers) - [Deprecated Svelte 3 and 4 features](./deprecated) diff --git a/examples/wrappers/child.svelte b/examples/wrappers/child.svelte new file mode 100644 index 0000000..c0fbb19 --- /dev/null +++ b/examples/wrappers/child.svelte @@ -0,0 +1,14 @@ + + +
+ {#each messages.current as message (message.id)} +

{message.text}

+
+ {/each} +
diff --git a/examples/wrappers/child.test.js b/examples/wrappers/child.test.js new file mode 100644 index 0000000..e9cc3eb --- /dev/null +++ b/examples/wrappers/child.test.js @@ -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') +}) diff --git a/examples/wrappers/readme.md b/examples/wrappers/readme.md new file mode 100644 index 0000000..37402bd --- /dev/null +++ b/examples/wrappers/readme.md @@ -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 + + +{@render children?.()} +``` + +## `child.svelte` + +```svelte file=./child.svelte + + +
+ {#each messages.current as message (message.id)} +

{message.text}

+
+ {/each} +
+``` + +## `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') +}) +``` diff --git a/examples/wrappers/wrapper.svelte b/examples/wrappers/wrapper.svelte new file mode 100644 index 0000000..098b03e --- /dev/null +++ b/examples/wrappers/wrapper.svelte @@ -0,0 +1,13 @@ + + +{@render children?.()} diff --git a/packages/svelte-core/README.md b/packages/svelte-core/README.md index 174399a..8010664 100644 --- a/packages/svelte-core/README.md +++ b/packages/svelte-core/README.md @@ -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 | `
` appended to `document.body` | -| `component` | [component exports][svelte-mount-docs] | The component's exports from `mount` | N/A | -| `rerender` | `(props: Partial) => Promise` | 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 | `
` 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) => Promise` | 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) @@ -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 @@ -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 | | -------------- | ------------------------------------ | ---------------------------------------- | ----------------------------------- | @@ -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) => Promise` | 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) => Promise` | Update the component's props | ### `cleanup` diff --git a/packages/svelte-core/src/mount.js b/packages/svelte-core/src/mount.js index 2927437..3a05f54 100644 --- a/packages/svelte-core/src/mount.js +++ b/packages/svelte-core/src/mount.js @@ -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. @@ -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} componentImport + * @returns {import('../types.js').ComponentType} + */ +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} Component + * @param {import('../types.js').MountOptions} mountOptions + * @param {import('../types.js').SetupOptions} [setupOptions] + * @returns {{componentToMount: import('../types.js').ComponentType, mountOptions: import('../types.js').MountOptions, 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: 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} Component * @param {import('../types.js').MountOptions} options + * @param {import('../types.js').SetupOptions} [setupOptions] * @returns {import('../types.js').MountResult} */ -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) { @@ -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() diff --git a/packages/svelte-core/src/render.js b/packages/svelte-core/src/render.js index 0c0ff28..bfe495c 100644 --- a/packages/svelte-core/src/render.js +++ b/packages/svelte-core/src/render.js @@ -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} Component - The component to render. * @param {import('../types.js').ComponentOptions} 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} The rendered component. + * @param {import('../types.js').SetupOptions} setupOptions - Customize how the document and component is set up. + * @returns {import('../types.js').RenderResult} 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 } } diff --git a/packages/svelte-core/src/setup.js b/packages/svelte-core/src/setup.js index 04c6c4f..5e2b91b 100644 --- a/packages/svelte-core/src/setup.js +++ b/packages/svelte-core/src/setup.js @@ -56,9 +56,11 @@ const validateOptions = (options) => { * Set up the document to render a component. * * @template {import('../types.js').Component} C + * @template {import('../types.js').Component} [W=never] + * * @param {import('../types.js').ComponentOptions} componentOptions - props or mount options - * @param {import('../types.js').SetupOptions} setupOptions - base element of the document to bind any queries - * @returns {import('../types.js').SetupResult} + * @param {import('../types.js').SetupOptions} setupOptions - base element of the document to bind any queries + * @returns {import('../types.js').SetupResult} */ const setup = (componentOptions, setupOptions = {}) => { const mountOptions = validateOptions(componentOptions) diff --git a/packages/svelte-core/src/wrapper-scaffold.svelte b/packages/svelte-core/src/wrapper-scaffold.svelte new file mode 100644 index 0000000..7679834 --- /dev/null +++ b/packages/svelte-core/src/wrapper-scaffold.svelte @@ -0,0 +1,21 @@ + + + + + diff --git a/packages/svelte-core/types.d.ts b/packages/svelte-core/types.d.ts index 1a3b3ab..418beec 100644 --- a/packages/svelte-core/types.d.ts +++ b/packages/svelte-core/types.d.ts @@ -91,19 +91,25 @@ export type Rerender = ( ) => Promise /** The result of mounting a component into the document. */ -export interface MountResult { +export interface MountResult { /** The mounted component's exports. */ component: Exports + /** The mounted wrapper's exports, if a wrapper was provided. */ + wrapper: Exports /** Unmount the component. */ unmount: () => void /** Rerender the component. */ rerender: Rerender } -/** Options for configuring the document. */ -export interface SetupOptions { +/** Options for configuring the component and document. */ +export interface SetupOptions { /** The base document element, `document.body` if unspecified. */ baseElement?: HTMLElement + /** A wrapper component. */ + wrapper?: ComponentImport + /** Wrapper component props. */ + wrapperProps?: Props } /** The result of setting up the document for rendering. */ @@ -117,13 +123,18 @@ export interface SetupResult { } /** The result of setting up the document and rendering the component. */ -export interface RenderResult { +export interface RenderResult< + C extends Component, + W extends Component = never, +> { /** The base document element, usually `document.body`. */ baseElement: HTMLElement /** The component's immediate container element, usually a `
` appended to `document.body`. */ container: HTMLElement /** The mounted component's exports. */ component: Exports + /** The mounted wrapper's exports, if a wrapper was provided. */ + wrapper: Exports /** Unmount the component. */ unmount: () => void /** Rerender the component. */ diff --git a/packages/svelte/src/pure.js b/packages/svelte/src/pure.js index 081c772..8b0281d 100644 --- a/packages/svelte/src/pure.js +++ b/packages/svelte/src/pure.js @@ -13,7 +13,8 @@ import * as Svelte from 'svelte' * Customize how Testing Library sets up the document and binds queries. * * @template {DomTestingLibrary.Queries} [Q=typeof DomTestingLibrary.queries] - * @typedef {import('@testing-library/svelte-core/types').SetupOptions & { queries?: Q }} RenderOptions + * @template {import('@testing-library/svelte-core/types').Component} [W=never] + * @typedef {import('@testing-library/svelte-core/types').SetupOptions & { queries?: Q }} RenderOptions */ /** @@ -21,11 +22,13 @@ import * as Svelte from 'svelte' * * @template {import('@testing-library/svelte-core/types').Component} C * @template {DomTestingLibrary.Queries} [Q=typeof DomTestingLibrary.queries] + * @template {import('@testing-library/svelte-core/types').Component} [W=never] * * @typedef {{ * container: HTMLElement * baseElement: HTMLElement * component: import('@testing-library/svelte-core/types').Exports + * wrapper: import('@testing-library/svelte-core/types').Exports * debug: (el?: HTMLElement | DocumentFragment) => void * rerender: import('@testing-library/svelte-core/types').Rerender * unmount: () => void @@ -39,18 +42,16 @@ import * as Svelte from 'svelte' * * @template {import('@testing-library/svelte-core/types').Component} C * @template {DomTestingLibrary.Queries} [Q=typeof DomTestingLibrary.queries] + * @template {import('@testing-library/svelte-core/types').Component} [W=never] * * @param {import('@testing-library/svelte-core/types').ComponentImport} Component - The component to render. * @param {import('@testing-library/svelte-core/types').ComponentOptions} options - Customize how Svelte renders the component. - * @param {RenderOptions} renderOptions - Customize how Testing Library sets up the document and binds queries. - * @returns {RenderResult} The rendered component and bound testing functions. + * @param {RenderOptions} renderOptions - Customize how Testing Library sets up the document and binds queries. + * @returns {RenderResult} The rendered component and bound testing functions. */ const render = (Component, options = {}, renderOptions = {}) => { - const { baseElement, container, component, unmount, rerender } = Core.render( - Component, - options, - renderOptions - ) + const { baseElement, container, component, wrapper, unmount, rerender } = + Core.render(Component, options, renderOptions) const queries = DomTestingLibrary.getQueriesForElement( baseElement, @@ -61,6 +62,7 @@ const render = (Component, options = {}, renderOptions = {}) => { baseElement, container, component, + wrapper, rerender, unmount, debug: (el = baseElement) => console.log(DomTestingLibrary.prettyDOM(el)), diff --git a/tests/_env.js b/tests/_env.js index 96b30f4..aaa82b6 100644 --- a/tests/_env.js +++ b/tests/_env.js @@ -24,3 +24,7 @@ export const COMPONENT_FIXTURES = [ isEnabled: IS_SVELTE_5, }, ].filter(({ isEnabled }) => isEnabled) + +export const WRAPPER = IS_SVELTE_5 + ? './fixtures/WrapperRunes.svelte' + : './fixtures/Wrapper.svelte' diff --git a/tests/fixtures/Wrapped.svelte b/tests/fixtures/Wrapped.svelte new file mode 100644 index 0000000..13f51cc --- /dev/null +++ b/tests/fixtures/Wrapped.svelte @@ -0,0 +1,12 @@ + + +

{wrappedContext.greeting} {name}{punctuation}

+ diff --git a/tests/fixtures/Wrapper.svelte b/tests/fixtures/Wrapper.svelte new file mode 100644 index 0000000..ea645f6 --- /dev/null +++ b/tests/fixtures/Wrapper.svelte @@ -0,0 +1,15 @@ + + +
diff --git a/tests/fixtures/WrapperRunes.svelte b/tests/fixtures/WrapperRunes.svelte new file mode 100644 index 0000000..648c94b --- /dev/null +++ b/tests/fixtures/WrapperRunes.svelte @@ -0,0 +1,17 @@ + + +
+ {@render children?.()} +
diff --git a/tests/render-runes.test-d.ts b/tests/render-runes.test-d.ts index 1e42544..df8afd6 100644 --- a/tests/render-runes.test-d.ts +++ b/tests/render-runes.test-d.ts @@ -2,10 +2,11 @@ import * as subject from '@testing-library/svelte' import { expectTypeOf } from 'expect-type' import { describe, test, vi } from 'vitest' +import UntypedComponent from './fixtures/Comp.svelte' import LegacyComponent from './fixtures/Typed.svelte' import Component from './fixtures/TypedRunes.svelte' -describe('types', () => { +describe('types (runes)', () => { test('render is a function that accepts a Svelte component', () => { subject.render(Component, { name: 'Alice', count: 42 }) subject.render(Component, { props: { name: 'Alice', count: 42 } }) @@ -32,11 +33,31 @@ describe('types', () => { expectTypeOf(result).toExtend<{ container: HTMLElement component: { hello: string } + wrapper: never debug: (el?: HTMLElement) => void rerender: (props: { name?: string; count?: number }) => Promise unmount: () => void }>() }) + + test('render function accepts and returns wrapper', () => { + const result = subject.render( + UntypedComponent, + {}, + { wrapper: Component, wrapperProps: { name: 'Alice', count: 42 } } + ) + + expectTypeOf(result).toExtend<{ wrapper: { hello: string } }>() + }) + + test('invalid wrapper props are rejected', () => { + subject.render( + UntypedComponent, + {}, + // @ts-expect-error: count should be a number + { wrapper: Component, wrapperProps: { name: 'Alice', count: '42' } } + ) + }) }) describe('legacy component types', () => { diff --git a/tests/render.test-d.ts b/tests/render.test-d.ts index eb1c639..2845141 100644 --- a/tests/render.test-d.ts +++ b/tests/render.test-d.ts @@ -3,6 +3,7 @@ import { expectTypeOf } from 'expect-type' import { ComponentProps } from 'svelte' import { describe, test } from 'vitest' +import UntypedComponent from './fixtures/Comp.svelte' import Component from './fixtures/Typed.svelte' describe('types', () => { @@ -40,6 +41,7 @@ describe('types', () => { expectTypeOf(result).toExtend<{ container: HTMLElement component: { hello: string } + wrapper: never debug: (el?: HTMLElement) => void rerender: (props: { name?: string; count?: number }) => Promise unmount: () => void @@ -55,4 +57,23 @@ describe('types', () => { // @ts-expect-error: name should be a string renderSubject(Component, { name: 42 }) }) + + test('render function accepts and returns wrapper', () => { + const result = subject.render( + UntypedComponent, + {}, + { wrapper: Component, wrapperProps: { name: 'Alice', count: 42 } } + ) + + expectTypeOf(result).toExtend<{ wrapper: { hello: string } }>() + }) + + test('invalid wrapper props are rejected', () => { + subject.render( + Component, + { name: 'Alice', count: 42 }, + // @ts-expect-error: count should be a number + { wrapper: Component, wrapperProps: { name: 'Alice', count: '42' } } + ) + }) }) diff --git a/tests/tsconfig.legacy.json b/tests/tsconfig.legacy.json index d32648e..128395f 100644 --- a/tests/tsconfig.legacy.json +++ b/tests/tsconfig.legacy.json @@ -4,6 +4,7 @@ "render-runes.test-d.ts", "fixtures/CompRunes.svelte", "fixtures/PropCloner.svelte", - "fixtures/TypedRunes.svelte" + "fixtures/TypedRunes.svelte", + "fixtures/WrapperRunes.svelte" ] } diff --git a/tests/wrapper.test.js b/tests/wrapper.test.js new file mode 100644 index 0000000..f013a16 --- /dev/null +++ b/tests/wrapper.test.js @@ -0,0 +1,80 @@ +import { render, screen } from '@testing-library/svelte' +import { userEvent } from '@testing-library/user-event' +import { beforeAll, describe, expect, test } from 'vitest' + +import { IS_SVELTE_5, WRAPPER } from './_env.js' +import WrappedComp from './fixtures/Wrapped.svelte' + +describe('wrapper', () => { + let Wrapper + + beforeAll(async () => { + Wrapper = await import(WRAPPER) + }) + + test('renders component inside wrapper', () => { + render( + WrappedComp, + { name: 'world', punctuation: '!' }, + { wrapper: Wrapper, wrapperProps: { greeting: 'hello' } } + ) + + expect(screen.getByText('hello world!')).toBeInTheDocument() + }) + + test('rerenders component inside wrapper', async () => { + const { rerender } = render( + WrappedComp, + { name: 'world', punctuation: '!' }, + { wrapper: Wrapper, wrapperProps: { greeting: 'hello' } } + ) + + await rerender({ name: 'mundo' }) + + expect(screen.getByText('hello mundo!')).toBeInTheDocument() + }) + + test.runIf(IS_SVELTE_5)('binds props inside wrapper', async () => { + const user = userEvent.setup() + let value = '' + + render( + WrappedComp, + { + name: '', + get value() { + return value + }, + set value(nextValue) { + value = nextValue + }, + }, + { wrapper: Wrapper, wrapperProps: { greeting: '' } } + ) + + const input = screen.getByRole('textbox') + await user.type(input, 'hello world') + + expect(value).toBe('hello world') + }) + + test('returns wrapped component instance', () => { + const { component } = render( + WrappedComp, + { name: 'world' }, + { wrapper: Wrapper, wrapperProps: { greeting: 'hello' } } + ) + + expect(component.wrappedContext.greeting).toBe('hello') + }) + + test('returns wrapper component instance instance', () => { + const { wrapper } = render( + WrappedComp, + { name: 'world' }, + { wrapper: Wrapper, wrapperProps: { greeting: 'hello' } } + ) + + expect(wrapper.wrapperContext.greeting).toBe('hello') + }) +}) From dc080f075680507f16a318abaf62868c67e662b8 Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Sat, 20 Jun 2026 15:56:15 -0400 Subject: [PATCH 2/2] fixup: cleanup --- examples/readme.md | 2 +- packages/svelte-core/src/mount.js | 2 +- packages/svelte-core/src/setup.js | 5 ++--- tests/wrapper.test.js | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/examples/readme.md b/examples/readme.md index e477c45..08cef8f 100644 --- a/examples/readme.md +++ b/examples/readme.md @@ -5,5 +5,5 @@ - [Snippets](./snippets) - [Contexts](./contexts) - [Binds](./binds) -- [Wrappers]('./wrappers) +- [Wrappers](./wrappers) - [Deprecated Svelte 3 and 4 features](./deprecated) diff --git a/packages/svelte-core/src/mount.js b/packages/svelte-core/src/mount.js index 3a05f54..e21ae9c 100644 --- a/packages/svelte-core/src/mount.js +++ b/packages/svelte-core/src/mount.js @@ -113,7 +113,7 @@ const setupComponent = (Component, mountOptions, setupOptions = {}) => { ...mountOptions, props: { wrapper: unwrapComponentImport(wrapper), - wrapperProps: wrapperProps, + wrapperProps, component: componentToMount, componentProps: mountOptions.props, }, diff --git a/packages/svelte-core/src/setup.js b/packages/svelte-core/src/setup.js index 5e2b91b..4fa560c 100644 --- a/packages/svelte-core/src/setup.js +++ b/packages/svelte-core/src/setup.js @@ -56,11 +56,10 @@ const validateOptions = (options) => { * Set up the document to render a component. * * @template {import('../types.js').Component} C - * @template {import('../types.js').Component} [W=never] * * @param {import('../types.js').ComponentOptions} componentOptions - props or mount options - * @param {import('../types.js').SetupOptions} setupOptions - base element of the document to bind any queries - * @returns {import('../types.js').SetupResult} + * @param {import('../types.js').SetupOptions} setupOptions - base element of the document to bind any queries + * @returns {import('../types.js').SetupResult} */ const setup = (componentOptions, setupOptions = {}) => { const mountOptions = validateOptions(componentOptions) diff --git a/tests/wrapper.test.js b/tests/wrapper.test.js index f013a16..79159f3 100644 --- a/tests/wrapper.test.js +++ b/tests/wrapper.test.js @@ -68,7 +68,7 @@ describe('wrapper', () => { expect(component.wrappedContext.greeting).toBe('hello') }) - test('returns wrapper component instance instance', () => { + test('returns wrapper component instance', () => { const { wrapper } = render( WrappedComp, { name: 'world' },