Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 to be rendered directly 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>
{#slot label()}
<span>Hi!</span>
{/slot}
</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 to be rendered directly 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>
{#slot label()}
<span>Hi!</span>
{/slot}
</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 to be rendered directly 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 to be rendered directly 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