Skip to content

Commit c287bd5

Browse files
Rich-Harristrueadm
andauthored
Raw snippet alternative (#12425)
* feat: add createRawSnippet API * handle missing hydrate function, improve types * fix * tweak types * beef up test * build * types * oops this was temporary * typo * regenerate types * make mount/render optional, error if missing * move code to new module * test hydration * simpler createRawSnippet API * regenerate types * change signature * docs * h1 -> node * allow `setup` to return a teardown function --------- Co-authored-by: Dominic Gannaway <[email protected]>
1 parent 9666215 commit c287bd5

File tree

17 files changed

+216
-2
lines changed

17 files changed

+216
-2
lines changed

.changeset/fresh-zoos-burn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
feat: add createRawSnippet API

packages/svelte/src/index-client.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,5 @@ export {
190190
tick,
191191
untrack
192192
} from './internal/client/runtime.js';
193+
194+
export { createRawSnippet } from './internal/client/dom/blocks/snippet.js';

packages/svelte/src/index-server.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,5 @@ export function unmount() {
3535
export async function tick() {}
3636

3737
export { getAllContexts, getContext, hasContext, setContext } from './internal/server/context.js';
38+
39+
export { createRawSnippet } from './internal/server/blocks/snippet.js';

packages/svelte/src/internal/client/dom/blocks/snippet.js

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
/** @import { Snippet } from 'svelte' */
12
/** @import { Effect, TemplateNode } from '#client' */
3+
/** @import { Getters } from '#shared' */
24
import { add_snippet_symbol } from '../../../shared/validate.js';
35
import { EFFECT_TRANSPARENT } from '../../constants.js';
4-
import { branch, block, destroy_effect } from '../../reactivity/effects.js';
6+
import { branch, block, destroy_effect, teardown } from '../../reactivity/effects.js';
57
import {
68
dev_current_component_function,
79
set_dev_current_component_function
810
} from '../../runtime.js';
9-
import { hydrate_node, hydrating } from '../hydration.js';
11+
import { hydrate_next, hydrate_node, hydrating } from '../hydration.js';
12+
import { create_fragment_from_html } from '../reconciler.js';
13+
import { assign_nodes } from '../template.js';
1014

1115
/**
1216
* @template {(node: TemplateNode, ...args: any[]) => void} SnippetFn
@@ -60,3 +64,40 @@ export function wrap_snippet(component, fn) {
6064
}
6165
});
6266
}
67+
68+
/**
69+
* Create a snippet programmatically
70+
* @template {unknown[]} Params
71+
* @param {(...params: Getters<Params>) => {
72+
* render: () => string
73+
* setup?: (element: Element) => void
74+
* }} fn
75+
* @returns {Snippet<Params>}
76+
*/
77+
export function createRawSnippet(fn) {
78+
return add_snippet_symbol(
79+
(/** @type {TemplateNode} */ anchor, /** @type {Getters<Params>} */ ...params) => {
80+
var snippet = fn(...params);
81+
82+
/** @type {Element} */
83+
var element;
84+
85+
if (hydrating) {
86+
element = /** @type {Element} */ (hydrate_node);
87+
hydrate_next();
88+
} else {
89+
var html = snippet.render().trim();
90+
var fragment = create_fragment_from_html(html);
91+
element = /** @type {Element} */ (fragment.firstChild);
92+
anchor.before(element);
93+
}
94+
95+
const result = snippet.setup?.(element);
96+
assign_nodes(element, element);
97+
98+
if (typeof result === 'function') {
99+
teardown(result);
100+
}
101+
}
102+
);
103+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/** @import { Snippet } from 'svelte' */
2+
/** @import { Payload } from '#server' */
3+
/** @import { Getters } from '#shared' */
4+
import { add_snippet_symbol } from '../../shared/validate.js';
5+
6+
/**
7+
* Create a snippet programmatically
8+
* @template {unknown[]} Params
9+
* @param {(...params: Getters<Params>) => {
10+
* render: () => string
11+
* setup?: (element: Element) => void
12+
* }} fn
13+
* @returns {Snippet<Params>}
14+
*/
15+
export function createRawSnippet(fn) {
16+
return add_snippet_symbol((/** @type {Payload} */ payload, /** @type {Params} */ ...args) => {
17+
var getters = /** @type {Getters<Params>} */ (args.map((value) => () => value));
18+
payload.out += fn(...getters)
19+
.render()
20+
.trim();
21+
});
22+
}

packages/svelte/src/internal/shared/types.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@ export type SourceLocation =
77
| [line: number, column: number]
88
| [line: number, column: number, SourceLocation[]];
99

10+
export type Getters<T> = {
11+
[K in keyof T]: () => T[K];
12+
};
13+
1014
export type Snapshot<T> = ReturnType<typeof $state.snapshot<T>>;

packages/svelte/src/internal/shared/validate.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/** @import { TemplateNode } from '#client' */
2+
/** @import { Getters } from '#shared' */
13
import { is_void } from '../../constants.js';
24
import * as w from './warnings.js';
35
import * as e from './errors.js';
@@ -6,6 +8,7 @@ const snippet_symbol = Symbol.for('svelte.snippet');
68

79
/**
810
* @param {any} fn
11+
* @returns {import('svelte').Snippet}
912
*/
1013
export function add_snippet_symbol(fn) {
1114
fn[snippet_symbol] = true;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
snapshot(target) {
5+
return {
6+
p: target.querySelector('p')
7+
};
8+
}
9+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<!--[--><p>hydrated</p><!--]-->
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<script>
2+
import { createRawSnippet } from 'svelte';
3+
4+
const snippet = createRawSnippet(() => ({
5+
render: () => `
6+
<p>rendered</p>
7+
`,
8+
setup(p) {
9+
p.textContent = 'hydrated';
10+
}
11+
}));
12+
</script>
13+
14+
{@render snippet()}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { flushSync } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
test({ target, assert, logs }) {
6+
const button = target.querySelector('button');
7+
8+
flushSync(() => button?.click());
9+
assert.deepEqual(logs, ['tearing down']);
10+
}
11+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<script>
2+
import { createRawSnippet } from 'svelte';
3+
4+
let show = $state(true);
5+
6+
const snippet = createRawSnippet(() => ({
7+
render: () => `<hr>`,
8+
setup(p) {
9+
return () => console.log('tearing down')
10+
}
11+
}));
12+
</script>
13+
14+
<button onclick={() => show = !show}>click</button>
15+
16+
{#if show}
17+
{@render snippet()}
18+
{/if}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { flushSync } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
compileOptions: {
6+
dev: true // Render in dev mode to check that the validation error is not thrown
7+
},
8+
9+
html: `<button>click</button><p>clicks: 0</p>`,
10+
11+
test({ target, assert }) {
12+
const button = target.querySelector('button');
13+
14+
flushSync(() => button?.click());
15+
assert.htmlEqual(target.innerHTML, `<button>click</button><p>clicks: 1</p>`);
16+
}
17+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<script>
2+
import { createRawSnippet } from 'svelte';
3+
4+
let count = $state(0);
5+
6+
const hello = createRawSnippet((count) => ({
7+
render: () => `
8+
<p>clicks: ${count()}</p>
9+
`,
10+
setup(p) {
11+
$effect(() => {
12+
p.textContent = `clicks: ${count()}`
13+
});
14+
}
15+
}));
16+
</script>
17+
18+
<button onclick={() => count += 1}>click</button>
19+
20+
{@render hello(count)}

packages/svelte/types/index.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,13 @@ declare module 'svelte' {
365365
export function flushSync(fn?: (() => void) | undefined): void;
366366
/** Anything except a function */
367367
type NotFunction<T> = T extends Function ? never : T;
368+
/**
369+
* Create a snippet programmatically
370+
* */
371+
export function createRawSnippet<Params extends unknown[]>(fn: (...params: Getters<Params>) => {
372+
render: () => string;
373+
setup?: (element: Element) => void;
374+
}): Snippet<Params>;
368375
/**
369376
* Mounts a component to the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component.
370377
* Transitions will play during the initial render unless the `intro` option is set to `false`.
@@ -450,6 +457,9 @@ declare module 'svelte' {
450457
* https://svelte.dev/docs/svelte#getallcontexts
451458
* */
452459
export function getAllContexts<T extends Map<any, any> = Map<any, any>>(): T;
460+
type Getters<T> = {
461+
[K in keyof T]: () => T[K];
462+
};
453463

454464
export {};
455465
}

sites/svelte-5-preview/src/routes/docs/content/01-api/03-snippets.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,10 @@ We can tighten things up further by declaring a generic, so that `data` and `row
256256
</script>
257257
```
258258

259+
## Creating snippets programmatically
260+
261+
In advanced scenarios, you may need to create a snippet programmatically. For this, you can use [`createRawSnippet`](/docs/imports#svelte-createrawsnippet)
262+
259263
## Snippets and slots
260264

261265
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.

sites/svelte-5-preview/src/routes/docs/content/01-api/05-imports.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,37 @@ To prevent something from being treated as an `$effect`/`$derived` dependency, u
9393
</script>
9494
```
9595

96+
### `createRawSnippet`
97+
98+
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:
99+
100+
```js
101+
import { createRawSnippet } from 'svelte';
102+
103+
const greet = createRawSnippet((name) => {
104+
return {
105+
render: () => `
106+
<h1>Hello ${name()}!</h1>
107+
`,
108+
setup: (node) => {
109+
$effect(() => {
110+
node.textContent = `Hello ${name()}!`;
111+
});
112+
}
113+
};
114+
});
115+
```
116+
117+
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.
118+
119+
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:
120+
121+
```svelte
122+
{@render greet(name)}
123+
```
124+
125+
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.
126+
96127
## `svelte/reactivity`
97128

98129
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)

0 commit comments

Comments
 (0)