diff --git a/.changeset/fresh-zoos-burn.md b/.changeset/fresh-zoos-burn.md new file mode 100644 index 000000000000..6b68a0272656 --- /dev/null +++ b/.changeset/fresh-zoos-burn.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +feat: add createRawSnippet API diff --git a/packages/svelte/src/index-client.js b/packages/svelte/src/index-client.js index 37fdb9cf6c65..24f8d99a1203 100644 --- a/packages/svelte/src/index-client.js +++ b/packages/svelte/src/index-client.js @@ -190,3 +190,5 @@ export { tick, untrack } from './internal/client/runtime.js'; + +export { createRawSnippet } from './internal/client/dom/blocks/snippet.js'; diff --git a/packages/svelte/src/index-server.js b/packages/svelte/src/index-server.js index 5bcb13e5f2ef..e5590fba504a 100644 --- a/packages/svelte/src/index-server.js +++ b/packages/svelte/src/index-server.js @@ -35,3 +35,5 @@ export function unmount() { export async function tick() {} export { getAllContexts, getContext, hasContext, setContext } from './internal/server/context.js'; + +export { createRawSnippet } from './internal/server/blocks/snippet.js'; diff --git a/packages/svelte/src/internal/client/dom/blocks/snippet.js b/packages/svelte/src/internal/client/dom/blocks/snippet.js index c0fc350ce45c..a920f6db3eda 100644 --- a/packages/svelte/src/internal/client/dom/blocks/snippet.js +++ b/packages/svelte/src/internal/client/dom/blocks/snippet.js @@ -1,12 +1,16 @@ +/** @import { Snippet } from 'svelte' */ /** @import { Effect, TemplateNode } from '#client' */ +/** @import { Getters } from '#shared' */ import { add_snippet_symbol } from '../../../shared/validate.js'; import { EFFECT_TRANSPARENT } from '../../constants.js'; -import { branch, block, destroy_effect } from '../../reactivity/effects.js'; +import { branch, block, destroy_effect, teardown } from '../../reactivity/effects.js'; import { dev_current_component_function, set_dev_current_component_function } from '../../runtime.js'; -import { hydrate_node, hydrating } from '../hydration.js'; +import { hydrate_next, hydrate_node, hydrating } from '../hydration.js'; +import { create_fragment_from_html } from '../reconciler.js'; +import { assign_nodes } from '../template.js'; /** * @template {(node: TemplateNode, ...args: any[]) => void} SnippetFn @@ -60,3 +64,40 @@ export function wrap_snippet(component, fn) { } }); } + +/** + * Create a snippet programmatically + * @template {unknown[]} Params + * @param {(...params: Getters) => { + * render: () => string + * setup?: (element: Element) => void + * }} fn + * @returns {Snippet} + */ +export function createRawSnippet(fn) { + return add_snippet_symbol( + (/** @type {TemplateNode} */ anchor, /** @type {Getters} */ ...params) => { + var snippet = fn(...params); + + /** @type {Element} */ + var element; + + if (hydrating) { + element = /** @type {Element} */ (hydrate_node); + hydrate_next(); + } else { + var html = snippet.render().trim(); + var fragment = create_fragment_from_html(html); + element = /** @type {Element} */ (fragment.firstChild); + anchor.before(element); + } + + const result = snippet.setup?.(element); + assign_nodes(element, element); + + if (typeof result === 'function') { + teardown(result); + } + } + ); +} diff --git a/packages/svelte/src/internal/server/blocks/snippet.js b/packages/svelte/src/internal/server/blocks/snippet.js new file mode 100644 index 000000000000..b9f72063f4d7 --- /dev/null +++ b/packages/svelte/src/internal/server/blocks/snippet.js @@ -0,0 +1,22 @@ +/** @import { Snippet } from 'svelte' */ +/** @import { Payload } from '#server' */ +/** @import { Getters } from '#shared' */ +import { add_snippet_symbol } from '../../shared/validate.js'; + +/** + * Create a snippet programmatically + * @template {unknown[]} Params + * @param {(...params: Getters) => { + * render: () => string + * setup?: (element: Element) => void + * }} fn + * @returns {Snippet} + */ +export function createRawSnippet(fn) { + return add_snippet_symbol((/** @type {Payload} */ payload, /** @type {Params} */ ...args) => { + var getters = /** @type {Getters} */ (args.map((value) => () => value)); + payload.out += fn(...getters) + .render() + .trim(); + }); +} diff --git a/packages/svelte/src/internal/shared/types.d.ts b/packages/svelte/src/internal/shared/types.d.ts index 76340531e914..a97a61af67a3 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -7,4 +7,8 @@ export type SourceLocation = | [line: number, column: number] | [line: number, column: number, SourceLocation[]]; +export type Getters = { + [K in keyof T]: () => T[K]; +}; + export type Snapshot = ReturnType>; diff --git a/packages/svelte/src/internal/shared/validate.js b/packages/svelte/src/internal/shared/validate.js index 12d852d53782..e08f3ddab1fa 100644 --- a/packages/svelte/src/internal/shared/validate.js +++ b/packages/svelte/src/internal/shared/validate.js @@ -1,3 +1,5 @@ +/** @import { TemplateNode } from '#client' */ +/** @import { Getters } from '#shared' */ import { is_void } from '../../constants.js'; import * as w from './warnings.js'; import * as e from './errors.js'; @@ -6,6 +8,7 @@ const snippet_symbol = Symbol.for('svelte.snippet'); /** * @param {any} fn + * @returns {import('svelte').Snippet} */ export function add_snippet_symbol(fn) { fn[snippet_symbol] = true; diff --git a/packages/svelte/tests/hydration/samples/snippet-raw-hydrate/_config.js b/packages/svelte/tests/hydration/samples/snippet-raw-hydrate/_config.js new file mode 100644 index 000000000000..a2f55c2641b1 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/snippet-raw-hydrate/_config.js @@ -0,0 +1,9 @@ +import { test } from '../../test'; + +export default test({ + snapshot(target) { + return { + p: target.querySelector('p') + }; + } +}); diff --git a/packages/svelte/tests/hydration/samples/snippet-raw-hydrate/_expected.html b/packages/svelte/tests/hydration/samples/snippet-raw-hydrate/_expected.html new file mode 100644 index 000000000000..8d9dde52c112 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/snippet-raw-hydrate/_expected.html @@ -0,0 +1 @@ +

hydrated

diff --git a/packages/svelte/tests/hydration/samples/snippet-raw-hydrate/main.svelte b/packages/svelte/tests/hydration/samples/snippet-raw-hydrate/main.svelte new file mode 100644 index 000000000000..84e1722908b2 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/snippet-raw-hydrate/main.svelte @@ -0,0 +1,14 @@ + + +{@render snippet()} diff --git a/packages/svelte/tests/runtime-runes/samples/snippet-raw-teardown/_config.js b/packages/svelte/tests/runtime-runes/samples/snippet-raw-teardown/_config.js new file mode 100644 index 000000000000..e05d82e929a2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/snippet-raw-teardown/_config.js @@ -0,0 +1,11 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ target, assert, logs }) { + const button = target.querySelector('button'); + + flushSync(() => button?.click()); + assert.deepEqual(logs, ['tearing down']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/snippet-raw-teardown/main.svelte b/packages/svelte/tests/runtime-runes/samples/snippet-raw-teardown/main.svelte new file mode 100644 index 000000000000..026208645c6e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/snippet-raw-teardown/main.svelte @@ -0,0 +1,18 @@ + + + + +{#if show} + {@render snippet()} +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/snippet-raw/_config.js b/packages/svelte/tests/runtime-runes/samples/snippet-raw/_config.js new file mode 100644 index 000000000000..818d81f11702 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/snippet-raw/_config.js @@ -0,0 +1,17 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true // Render in dev mode to check that the validation error is not thrown + }, + + html: `

clicks: 0

`, + + test({ target, assert }) { + const button = target.querySelector('button'); + + flushSync(() => button?.click()); + assert.htmlEqual(target.innerHTML, `

clicks: 1

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/snippet-raw/main.svelte b/packages/svelte/tests/runtime-runes/samples/snippet-raw/main.svelte new file mode 100644 index 000000000000..ab23de4f36f2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/snippet-raw/main.svelte @@ -0,0 +1,20 @@ + + + + +{@render hello(count)} diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index dff48ce8a187..b0c8c026d36c 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -365,6 +365,13 @@ declare module 'svelte' { export function flushSync(fn?: (() => void) | undefined): void; /** Anything except a function */ type NotFunction = T extends Function ? never : T; + /** + * Create a snippet programmatically + * */ + export function createRawSnippet(fn: (...params: Getters) => { + render: () => string; + setup?: (element: Element) => void; + }): Snippet; /** * Mounts a component to the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component. * Transitions will play during the initial render unless the `intro` option is set to `false`. @@ -450,6 +457,9 @@ declare module 'svelte' { * https://svelte.dev/docs/svelte#getallcontexts * */ export function getAllContexts = Map>(): T; + type Getters = { + [K in keyof T]: () => T[K]; + }; export {}; } diff --git a/sites/svelte-5-preview/src/routes/docs/content/01-api/03-snippets.md b/sites/svelte-5-preview/src/routes/docs/content/01-api/03-snippets.md index e10443f7505b..516300f2ea56 100644 --- a/sites/svelte-5-preview/src/routes/docs/content/01-api/03-snippets.md +++ b/sites/svelte-5-preview/src/routes/docs/content/01-api/03-snippets.md @@ -256,6 +256,10 @@ We can tighten things up further by declaring a generic, so that `data` and `row ``` +## Creating snippets programmatically + +In advanced scenarios, you may need to create a snippet programmatically. For this, you can use [`createRawSnippet`](/docs/imports#svelte-createrawsnippet) + ## Snippets and slots In Svelte 4, content can be passed to components using [slots](https://svelte.dev/docs/special-elements#slot). Snippets are more powerful and flexible, and as such slots are deprecated in Svelte 5. diff --git a/sites/svelte-5-preview/src/routes/docs/content/01-api/05-imports.md b/sites/svelte-5-preview/src/routes/docs/content/01-api/05-imports.md index af4118eaabae..d364f8242942 100644 --- a/sites/svelte-5-preview/src/routes/docs/content/01-api/05-imports.md +++ b/sites/svelte-5-preview/src/routes/docs/content/01-api/05-imports.md @@ -93,6 +93,37 @@ To prevent something from being treated as an `$effect`/`$derived` dependency, u ``` +### `createRawSnippet` + +An advanced API designed for people building frameworks that integrate with Svelte, `createRawSnippet` allows you to create [snippets](/docs/snippets) programmatically for use with `{@render ...}` tags: + +```js +import { createRawSnippet } from 'svelte'; + +const greet = createRawSnippet((name) => { + return { + render: () => ` +

Hello ${name()}!

+ `, + setup: (node) => { + $effect(() => { + node.textContent = `Hello ${name()}!`; + }); + } + }; +}); +``` + +The `render` function is called during server-side rendering, or during `mount` (but not during `hydrate`, because it already ran on the server), and must return HTML representing a single element. + +The `setup` function is called during `mount` or `hydrate` with that same element as its sole argument. It is responsible for ensuring that the DOM is updated when the arguments change their value — in this example, when `name` changes: + +```svelte +{@render greet(name)} +``` + +If `setup` returns a function, it will be called when the snippet is unmounted. If the snippet is fully static, you can omit the `setup` function altogether. + ## `svelte/reactivity` Svelte provides reactive `SvelteMap`, `SvelteSet`, `SvelteDate` and `SvelteURL` classes. These can be imported from `svelte/reactivity` and used just like their native counterparts. [Demo:](https://svelte-5-preview.vercel.app/#H4sIAAAAAAAAE32QwUrEMBBAf2XMpQrb9t7tFrx7UjxZYWM6NYFkEpJJ16X03yWK9OQeZ3iPecwqZmMxie5tFSQdik48hiAOgq-hDGlByygOIvkcVdn0SUUTeBhpZOOCjwwrvPxgr89PsMEcvYPqV2wjSsVmMXytjiMVR3lKDDlaOAHhZVfvK80cUte2-CVdsNgo79ogWVcPx5H6dj9M_V1dg9KSPjEBe2CNCZumgboeRuoNhczwYWjqFmkzntYcbROiZ6-83f5HtE9c3nADKUF_yEi9jnvQxVgLOUySEc464nwGSRMsRiEsGJO8mVeEbRAH4fxkZoOT6Dhm3N63b9_bGfOlAQAA)