Skip to content

fix: Throw on unrendered snippets in dev #15766

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

5 changes: 5 additions & 0 deletions .changeset/strong-pianos-promise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: Throw on unrendered snippets in `dev`
37 changes: 37 additions & 0 deletions documentation/docs/98-reference/.generated/shared-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,43 @@ Certain lifecycle methods can only be used during component initialisation. To f
<button onclick={handleClick}>click me</button>
```

### snippet_without_render_tag

```
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()}`.
```

A component throwing this error will look something like this (`children` is not being rendered):

```svelte
<script>
let { children } = $props();
</script>

{children}
```

...or like this (a parent component is passing a snippet where a non-snippet value is expected):

```svelte
<!--- file: Parent.svelte --->
<ChildComponent>
{#snippet label()}
<span>Hi!</span>
{/snippet}
</ChildComponent>
```

```svelte
<!--- file: Child.svelte --->
<script>
let { label } = $props();
</script>

<!-- This component doesn't expect a snippet, but the parent provided one -->
<p>{label}</p>
```

### store_invalid_shape

```
Expand Down
35 changes: 35 additions & 0 deletions packages/svelte/messages/shared-errors/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,41 @@ Certain lifecycle methods can only be used during component initialisation. To f
<button onclick={handleClick}>click me</button>
```

## snippet_without_render_tag

> 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()}`.

A component throwing this error will look something like this (`children` is not being rendered):

```svelte
<script>
let { children } = $props();
</script>

{children}
```

...or like this (a parent component is passing a snippet where a non-snippet value is expected):

```svelte
<!--- file: Parent.svelte --->
<ChildComponent>
{#snippet label()}
<span>Hi!</span>
{/snippet}
</ChildComponent>
```

```svelte
<!--- file: Child.svelte --->
<script>
let { label } = $props();
</script>

<!-- This component doesn't expect a snippet, but the parent provided one -->
<p>{label}</p>
```

## store_invalid_shape

> `%name%` is not a store with a `subscribe` method
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** @import { BlockStatement } from 'estree' */
/** @import { ArrowFunctionExpression, BlockStatement, CallExpression } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */
import { dev } from '../../../../state.js';
Expand All @@ -9,20 +9,27 @@ import * as b from '../../../../utils/builders.js';
* @param {ComponentContext} context
*/
export function SnippetBlock(node, context) {
const fn = b.function_declaration(
node.expression,
[b.id('$$payload'), ...node.parameters],
/** @type {BlockStatement} */ (context.visit(node.body))
);
const body = /** @type {BlockStatement} */ (context.visit(node.body));

if (dev) {
fn.body.body.unshift(b.stmt(b.call('$.validate_snippet_args', b.id('$$payload'))));
body.body.unshift(b.stmt(b.call('$.validate_snippet_args', b.id('$$payload'))));
}

/** @type {ArrowFunctionExpression | CallExpression} */
let fn = b.arrow([b.id('$$payload'), ...node.parameters], body);

if (dev) {
fn = b.call('$.prevent_snippet_stringification', fn);
}

const declaration = b.declaration('const', [b.declarator(node.expression, fn)]);

// @ts-expect-error - TODO remove this hack once $$render_inner for legacy bindings is gone
fn.___snippet = true;

if (node.metadata.can_hoist) {
context.state.hoisted.push(fn);
context.state.hoisted.push(declaration);
} else {
context.state.init.push(fn);
context.state.init.push(declaration);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { empty_comment, build_attribute_value } from './utils.js';
import * as b from '../../../../../utils/builders.js';
import { is_element_node } from '../../../../nodes.js';
import { dev } from '../../../../../state.js';

/**
* @param {AST.Component | AST.SvelteComponent | AST.SvelteSelf} node
Expand Down Expand Up @@ -238,7 +239,13 @@ export function build_inline_component(node, expression, context) {
)
) {
// create `children` prop...
push_prop(b.prop('init', b.id('children'), slot_fn));
push_prop(
b.prop(
'init',
b.id('children'),
dev ? b.call('$.prevent_snippet_stringification', slot_fn) : slot_fn
)
);

// and `$$slots.default: true` so that `<slot>` on the child works
serialized_slots.push(b.init(slot_name, b.true));
Expand Down
7 changes: 6 additions & 1 deletion packages/svelte/src/internal/client/dom/blocks/snippet.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as e from '../../errors.js';
import { DEV } from 'esm-env';
import { get_first_child, get_next_sibling } from '../operations.js';
import { noop } from '../../../shared/utils.js';
import { prevent_snippet_stringification } from '../../../shared/validate.js';

/**
* @template {(node: TemplateNode, ...args: any[]) => void} SnippetFn
Expand Down Expand Up @@ -60,7 +61,7 @@ export function snippet(node, get_snippet, ...args) {
* @param {(node: TemplateNode, ...args: any[]) => void} fn
*/
export function wrap_snippet(component, fn) {
return (/** @type {TemplateNode} */ node, /** @type {any[]} */ ...args) => {
const snippet = (/** @type {TemplateNode} */ node, /** @type {any[]} */ ...args) => {
var previous_component_function = dev_current_component_function;
set_dev_current_component_function(component);

Expand All @@ -70,6 +71,10 @@ export function wrap_snippet(component, fn) {
set_dev_current_component_function(previous_component_function);
}
};

prevent_snippet_stringification(snippet);

return snippet;
}

/**
Expand Down
3 changes: 2 additions & 1 deletion packages/svelte/src/internal/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,8 @@ export {
invalid_default_snippet,
validate_dynamic_element_tag,
validate_store,
validate_void_dynamic_element
validate_void_dynamic_element,
prevent_snippet_stringification
} from '../shared/validate.js';
export { strict_equals, equals } from './dev/equality.js';
export { log_if_contains_state } from './dev/console-log.js';
3 changes: 2 additions & 1 deletion packages/svelte/src/internal/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,8 @@ export { fallback } from '../shared/utils.js';
export {
invalid_default_snippet,
validate_dynamic_element_tag,
validate_void_dynamic_element
validate_void_dynamic_element,
prevent_snippet_stringification
} from '../shared/validate.js';

export { escape_html as escape };
15 changes: 15 additions & 0 deletions packages/svelte/src/internal/shared/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ export function lifecycle_outside_component(name) {
}
}

/**
* 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()}`.
* @returns {never}
*/
export function snippet_without_render_tag() {
if (DEV) {
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`);

error.name = 'Svelte error';
throw error;
} else {
throw new Error(`https://svelte.dev/e/snippet_without_render_tag`);
}
}

/**
* `%name%` is not a store with a `subscribe` method
* @param {string} name
Expand Down
12 changes: 12 additions & 0 deletions packages/svelte/src/internal/shared/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,15 @@ export function validate_store(store, name) {
e.store_invalid_shape(name);
}
}

/**
* @template {() => unknown} T
* @param {T} fn
*/
export function prevent_snippet_stringification(fn) {
fn.toString = () => {
e.snippet_without_render_tag();
return '';
};
return fn;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { test } from '../../test';

export default test({
compileOptions: {
dev: true
},
runtime_error: 'snippet_without_render_tag'
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{testSnippet}

{#snippet testSnippet()}
<p>hi again</p>
{/snippet}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { test } from '../../test';

export default test({});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{testSnippet}

{#snippet testSnippet()}
<p>hi again</p>
{/snippet}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { test } from '../../test';

export default test({});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script>
import UnrenderedChildren from './unrendered-children.svelte';
</script>

<UnrenderedChildren>Hi</UnrenderedChildren>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script>
let { children } = $props();
</script>

{children}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { test } from '../../test';

export default test({
compileOptions: {
dev: true
},
runtime_error: 'snippet_without_render_tag'
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script>
import UnrenderedChildren from './unrendered-children.svelte';
</script>

<UnrenderedChildren>Hi</UnrenderedChildren>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script>
let { children } = $props();
</script>

{children}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import * as $ from 'svelte/internal/server';
import TextInput from './Child.svelte';

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

export default function Bind_component_snippet($$payload) {
let value = '';
Expand Down