Skip to content

Commit e079ac9

Browse files
fix: Throw on unrendered snippets in dev (#15766)
1 parent 6a7e53f commit e079ac9

File tree

21 files changed

+192
-15
lines changed

21 files changed

+192
-15
lines changed

.changeset/strong-pianos-promise.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: Throw on unrendered snippets in `dev`

documentation/docs/98-reference/.generated/shared-errors.md

+37
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,43 @@ Certain lifecycle methods can only be used during component initialisation. To f
6060
<button onclick={handleClick}>click me</button>
6161
```
6262

63+
### snippet_without_render_tag
64+
65+
```
66+
Attempted to render a snippet without a `{@render}` block. This would cause the snippet code to be stringified instead of its content being rendered to the DOM. To fix this, change `{snippet}` to `{@render snippet()}`.
67+
```
68+
69+
A component throwing this error will look something like this (`children` is not being rendered):
70+
71+
```svelte
72+
<script>
73+
let { children } = $props();
74+
</script>
75+
76+
{children}
77+
```
78+
79+
...or like this (a parent component is passing a snippet where a non-snippet value is expected):
80+
81+
```svelte
82+
<!--- file: Parent.svelte --->
83+
<ChildComponent>
84+
{#snippet label()}
85+
<span>Hi!</span>
86+
{/snippet}
87+
</ChildComponent>
88+
```
89+
90+
```svelte
91+
<!--- file: Child.svelte --->
92+
<script>
93+
let { label } = $props();
94+
</script>
95+
96+
<!-- This component doesn't expect a snippet, but the parent provided one -->
97+
<p>{label}</p>
98+
```
99+
63100
### store_invalid_shape
64101

65102
```

packages/svelte/messages/shared-errors/errors.md

+35
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,41 @@ Certain lifecycle methods can only be used during component initialisation. To f
5252
<button onclick={handleClick}>click me</button>
5353
```
5454

55+
## snippet_without_render_tag
56+
57+
> Attempted to render a snippet without a `{@render}` block. This would cause the snippet code to be stringified instead of its content being rendered to the DOM. To fix this, change `{snippet}` to `{@render snippet()}`.
58+
59+
A component throwing this error will look something like this (`children` is not being rendered):
60+
61+
```svelte
62+
<script>
63+
let { children } = $props();
64+
</script>
65+
66+
{children}
67+
```
68+
69+
...or like this (a parent component is passing a snippet where a non-snippet value is expected):
70+
71+
```svelte
72+
<!--- file: Parent.svelte --->
73+
<ChildComponent>
74+
{#snippet label()}
75+
<span>Hi!</span>
76+
{/snippet}
77+
</ChildComponent>
78+
```
79+
80+
```svelte
81+
<!--- file: Child.svelte --->
82+
<script>
83+
let { label } = $props();
84+
</script>
85+
86+
<!-- This component doesn't expect a snippet, but the parent provided one -->
87+
<p>{label}</p>
88+
```
89+
5590
## store_invalid_shape
5691

5792
> `%name%` is not a store with a `subscribe` method
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/** @import { BlockStatement } from 'estree' */
1+
/** @import { ArrowFunctionExpression, BlockStatement, CallExpression } from 'estree' */
22
/** @import { AST } from '#compiler' */
33
/** @import { ComponentContext } from '../types.js' */
44
import { dev } from '../../../../state.js';
@@ -9,20 +9,27 @@ import * as b from '../../../../utils/builders.js';
99
* @param {ComponentContext} context
1010
*/
1111
export function SnippetBlock(node, context) {
12-
const fn = b.function_declaration(
13-
node.expression,
14-
[b.id('$$payload'), ...node.parameters],
15-
/** @type {BlockStatement} */ (context.visit(node.body))
16-
);
12+
const body = /** @type {BlockStatement} */ (context.visit(node.body));
13+
1714
if (dev) {
18-
fn.body.body.unshift(b.stmt(b.call('$.validate_snippet_args', b.id('$$payload'))));
15+
body.body.unshift(b.stmt(b.call('$.validate_snippet_args', b.id('$$payload'))));
1916
}
17+
18+
/** @type {ArrowFunctionExpression | CallExpression} */
19+
let fn = b.arrow([b.id('$$payload'), ...node.parameters], body);
20+
21+
if (dev) {
22+
fn = b.call('$.prevent_snippet_stringification', fn);
23+
}
24+
25+
const declaration = b.declaration('const', [b.declarator(node.expression, fn)]);
26+
2027
// @ts-expect-error - TODO remove this hack once $$render_inner for legacy bindings is gone
2128
fn.___snippet = true;
2229

2330
if (node.metadata.can_hoist) {
24-
context.state.hoisted.push(fn);
31+
context.state.hoisted.push(declaration);
2532
} else {
26-
context.state.init.push(fn);
33+
context.state.init.push(declaration);
2734
}
2835
}

packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js

+8-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { empty_comment, build_attribute_value } from './utils.js';
55
import * as b from '../../../../../utils/builders.js';
66
import { is_element_node } from '../../../../nodes.js';
7+
import { dev } from '../../../../../state.js';
78

89
/**
910
* @param {AST.Component | AST.SvelteComponent | AST.SvelteSelf} node
@@ -238,7 +239,13 @@ export function build_inline_component(node, expression, context) {
238239
)
239240
) {
240241
// create `children` prop...
241-
push_prop(b.prop('init', b.id('children'), slot_fn));
242+
push_prop(
243+
b.prop(
244+
'init',
245+
b.id('children'),
246+
dev ? b.call('$.prevent_snippet_stringification', slot_fn) : slot_fn
247+
)
248+
);
242249

243250
// and `$$slots.default: true` so that `<slot>` on the child works
244251
serialized_slots.push(b.init(slot_name, b.true));

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

+6-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import * as e from '../../errors.js';
1515
import { DEV } from 'esm-env';
1616
import { get_first_child, get_next_sibling } from '../operations.js';
1717
import { noop } from '../../../shared/utils.js';
18+
import { prevent_snippet_stringification } from '../../../shared/validate.js';
1819

1920
/**
2021
* @template {(node: TemplateNode, ...args: any[]) => void} SnippetFn
@@ -60,7 +61,7 @@ export function snippet(node, get_snippet, ...args) {
6061
* @param {(node: TemplateNode, ...args: any[]) => void} fn
6162
*/
6263
export function wrap_snippet(component, fn) {
63-
return (/** @type {TemplateNode} */ node, /** @type {any[]} */ ...args) => {
64+
const snippet = (/** @type {TemplateNode} */ node, /** @type {any[]} */ ...args) => {
6465
var previous_component_function = dev_current_component_function;
6566
set_dev_current_component_function(component);
6667

@@ -70,6 +71,10 @@ export function wrap_snippet(component, fn) {
7071
set_dev_current_component_function(previous_component_function);
7172
}
7273
};
74+
75+
prevent_snippet_stringification(snippet);
76+
77+
return snippet;
7378
}
7479

7580
/**

packages/svelte/src/internal/client/index.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,8 @@ export {
157157
invalid_default_snippet,
158158
validate_dynamic_element_tag,
159159
validate_store,
160-
validate_void_dynamic_element
160+
validate_void_dynamic_element,
161+
prevent_snippet_stringification
161162
} from '../shared/validate.js';
162163
export { strict_equals, equals } from './dev/equality.js';
163164
export { log_if_contains_state } from './dev/console-log.js';

packages/svelte/src/internal/server/index.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,8 @@ export { fallback } from '../shared/utils.js';
509509
export {
510510
invalid_default_snippet,
511511
validate_dynamic_element_tag,
512-
validate_void_dynamic_element
512+
validate_void_dynamic_element,
513+
prevent_snippet_stringification
513514
} from '../shared/validate.js';
514515

515516
export { escape_html as escape };

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

+15
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,21 @@ export function lifecycle_outside_component(name) {
4848
}
4949
}
5050

51+
/**
52+
* Attempted to render a snippet without a `{@render}` block. This would cause the snippet code to be stringified instead of its content being rendered to the DOM. To fix this, change `{snippet}` to `{@render snippet()}`.
53+
* @returns {never}
54+
*/
55+
export function snippet_without_render_tag() {
56+
if (DEV) {
57+
const error = new Error(`snippet_without_render_tag\nAttempted to render a snippet without a \`{@render}\` block. This would cause the snippet code to be stringified instead of its content being rendered to the DOM. To fix this, change \`{snippet}\` to \`{@render snippet()}\`.\nhttps://svelte.dev/e/snippet_without_render_tag`);
58+
59+
error.name = 'Svelte error';
60+
throw error;
61+
} else {
62+
throw new Error(`https://svelte.dev/e/snippet_without_render_tag`);
63+
}
64+
}
65+
5166
/**
5267
* `%name%` is not a store with a `subscribe` method
5368
* @param {string} name

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

+12
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,15 @@ export function validate_store(store, name) {
3535
e.store_invalid_shape(name);
3636
}
3737
}
38+
39+
/**
40+
* @template {() => unknown} T
41+
* @param {T} fn
42+
*/
43+
export function prevent_snippet_stringification(fn) {
44+
fn.toString = () => {
45+
e.snippet_without_render_tag();
46+
return '';
47+
};
48+
return fn;
49+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
compileOptions: {
5+
dev: true
6+
},
7+
runtime_error: 'snippet_without_render_tag'
8+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{testSnippet}
2+
3+
{#snippet testSnippet()}
4+
<p>hi again</p>
5+
{/snippet}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { test } from '../../test';
2+
3+
export default test({});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{testSnippet}
2+
3+
{#snippet testSnippet()}
4+
<p>hi again</p>
5+
{/snippet}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { test } from '../../test';
2+
3+
export default test({});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<script>
2+
import UnrenderedChildren from './unrendered-children.svelte';
3+
</script>
4+
5+
<UnrenderedChildren>Hi</UnrenderedChildren>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<script>
2+
let { children } = $props();
3+
</script>
4+
5+
{children}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
compileOptions: {
5+
dev: true
6+
},
7+
runtime_error: 'snippet_without_render_tag'
8+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<script>
2+
import UnrenderedChildren from './unrendered-children.svelte';
3+
</script>
4+
5+
<UnrenderedChildren>Hi</UnrenderedChildren>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<script>
2+
let { children } = $props();
3+
</script>
4+
5+
{children}

packages/svelte/tests/snapshot/samples/bind-component-snippet/_expected/server/index.svelte.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import * as $ from 'svelte/internal/server';
22
import TextInput from './Child.svelte';
33

4-
function snippet($$payload) {
4+
const snippet = ($$payload) => {
55
$$payload.out += `<!---->Something`;
6-
}
6+
};
77

88
export default function Bind_component_snippet($$payload) {
99
let value = '';

0 commit comments

Comments
 (0)