use types rather than runtime validation to prevent incorrect snippet/component usage

pull/12507/head
Rich Harris 2 years ago
parent 30b143cef0
commit 3a032887d5

@ -2,14 +2,6 @@
> `%name%(...)` can only be used during component initialisation > `%name%(...)` can only be used during component initialisation
## render_tag_invalid_argument
> The argument to `{@render ...}` must be a snippet function, not a component or a slot with a `let:` directive or some other kind of function. If you want to dynamically render one snippet or another, use `$derived` and pass its result to `{@render ...}`
## snippet_used_as_component
> A snippet must be rendered with `{@render ...}`
## store_invalid_shape ## store_invalid_shape
> `%name%` is not a store with a `subscribe` method > `%name%` is not a store with a `subscribe` method

@ -969,13 +969,7 @@ function serialize_inline_component(node, expression, context) {
lets.length === 0 && lets.length === 0 &&
children.default.every((node) => node.type !== 'SvelteFragment') children.default.every((node) => node.type !== 'SvelteFragment')
) { ) {
push_prop( push_prop(b.prop('init', b.id('children'), slot_fn));
b.prop(
'init',
b.id('children'),
context.state.options.dev ? b.call('$.add_snippet_symbol', slot_fn) : slot_fn
)
);
// We additionally add the default slot as a boolean, so that the slot render function on the other // We additionally add the default slot as a boolean, so that the slot render function on the other
// side knows it should get the content to render from $$props.children // side knows it should get the content to render from $$props.children
serialized_slots.push(b.init('default', b.true)); serialized_slots.push(b.init('default', b.true));
@ -1501,10 +1495,6 @@ const template_visitors = {
fn.___snippet = true; fn.___snippet = true;
// TODO hoist where possible // TODO hoist where possible
context.state.init.push(fn); context.state.init.push(fn);
if (context.state.options.dev) {
context.state.init.push(b.stmt(b.call('$.add_snippet_symbol', node.expression)));
}
}, },
Component(node, context) { Component(node, context) {
serialize_inline_component(node, b.id(node.name), context); serialize_inline_component(node, b.id(node.name), context);

@ -1,5 +1,6 @@
// This should contain all the public interfaces (not all of them are actually importable, check current Svelte for which ones are). // This should contain all the public interfaces (not all of them are actually importable, check current Svelte for which ones are).
import type { Getters } from '#shared';
import './ambient.js'; import './ambient.js';
/** /**
@ -104,6 +105,10 @@ export class SvelteComponent<
$set(props: Partial<Props>): void; $set(props: Partial<Props>): void;
} }
declare const brand: unique symbol;
type Brand<B> = { [brand]: B };
type Branded<T, B> = T & Brand<B>;
/** /**
* Can be used to create strongly typed Svelte components. * Can be used to create strongly typed Svelte components.
* *
@ -136,7 +141,8 @@ export interface Component<
* @param props The props passed to the component. * @param props The props passed to the component.
*/ */
( (
internal: unknown, this: void,
internal: Branded<{}, 'ComponentInternals'>,
props: Props props: Props
): { ): {
/** /**
@ -271,13 +277,12 @@ declare const SnippetReturn: unique symbol;
export interface Snippet<Parameters extends unknown[] = []> { export interface Snippet<Parameters extends unknown[] = []> {
( (
this: void, this: void,
internal: Branded<{}, 'SnippetInternals'>,
// this conditional allows tuples but not arrays. Arrays would indicate a // this conditional allows tuples but not arrays. Arrays would indicate a
// rest parameter type, which is not supported. If rest parameters are added // rest parameter type, which is not supported. If rest parameters are added
// in the future, the condition can be removed. // in the future, the condition can be removed.
...args: number extends Parameters['length'] ? never : Parameters ...args: number extends Parameters['length'] ? never : Getters<Parameters>
): typeof SnippetReturn & { ): void;
_: 'functions passed to {@render ...} tags must use the `Snippet` type imported from "svelte"';
};
} }
interface DispatchOptions { interface DispatchOptions {

@ -1,7 +1,6 @@
/** @import { Snippet } from 'svelte' */ /** @import { Snippet } from 'svelte' */
/** @import { Effect, TemplateNode } from '#client' */ /** @import { Effect, TemplateNode } from '#client' */
/** @import { Getters } from '#shared' */ /** @import { Getters } from '#shared' */
import { add_snippet_symbol } from '../../../shared/validate.js';
import { EFFECT_TRANSPARENT } from '../../constants.js'; import { EFFECT_TRANSPARENT } from '../../constants.js';
import { branch, block, destroy_effect, teardown } from '../../reactivity/effects.js'; import { branch, block, destroy_effect, teardown } from '../../reactivity/effects.js';
import { import {
@ -50,10 +49,11 @@ export function snippet(node, get_snippet, ...args) {
* In development, wrap the snippet function so that it passes validation, and so that the * In development, wrap the snippet function so that it passes validation, and so that the
* correct component context is set for ownership checks * correct component context is set for ownership checks
* @param {any} component * @param {any} component
* @param {(node: TemplateNode, ...args: any[]) => void} fn * @param {Snippet} fn
* @returns {Snippet}
*/ */
export function wrap_snippet(component, fn) { export function wrap_snippet(component, fn) {
return add_snippet_symbol((/** @type {TemplateNode} */ node, /** @type {any[]} */ ...args) => { return (node, ...args) => {
var previous_component_function = dev_current_component_function; var previous_component_function = dev_current_component_function;
set_dev_current_component_function(component); set_dev_current_component_function(component);
@ -62,7 +62,7 @@ export function wrap_snippet(component, fn) {
} finally { } finally {
set_dev_current_component_function(previous_component_function); set_dev_current_component_function(previous_component_function);
} }
}); };
} }
/** /**
@ -75,29 +75,27 @@ export function wrap_snippet(component, fn) {
* @returns {Snippet<Params>} * @returns {Snippet<Params>}
*/ */
export function createRawSnippet(fn) { export function createRawSnippet(fn) {
return add_snippet_symbol( return (anchor, ...params) => {
(/** @type {TemplateNode} */ anchor, /** @type {Getters<Params>} */ ...params) => { var snippet = fn(...params);
var snippet = fn(...params);
/** @type {Element} */ /** @type {Element} */
var element; var element;
if (hydrating) { if (hydrating) {
element = /** @type {Element} */ (hydrate_node); element = /** @type {Element} */ (hydrate_node);
hydrate_next(); hydrate_next();
} else { } else {
var html = snippet.render().trim(); var html = snippet.render().trim();
var fragment = create_fragment_from_html(html); var fragment = create_fragment_from_html(html);
element = /** @type {Element} */ (fragment.firstChild); element = /** @type {Element} */ (fragment.firstChild);
anchor.before(element); /** @type {TemplateNode} */ (/** @type {unknown} */ (anchor)).before(element);
} }
const result = snippet.setup?.(element); const result = snippet.setup?.(element);
assign_nodes(element, element); assign_nodes(element, element);
if (typeof result === 'function') { if (typeof result === 'function') {
teardown(result); teardown(result);
}
} }
); };
} }

@ -1,7 +1,6 @@
/** @import { Snippet } from 'svelte' */ /** @import { Snippet } from 'svelte' */
/** @import { Payload } from '#server' */ /** @import { Payload } from '#server' */
/** @import { Getters } from '#shared' */ /** @import { Getters } from '#shared' */
import { add_snippet_symbol } from '../../shared/validate.js';
/** /**
* Create a snippet programmatically * Create a snippet programmatically
@ -13,10 +12,10 @@ import { add_snippet_symbol } from '../../shared/validate.js';
* @returns {Snippet<Params>} * @returns {Snippet<Params>}
*/ */
export function createRawSnippet(fn) { export function createRawSnippet(fn) {
return add_snippet_symbol((/** @type {Payload} */ payload, /** @type {Params} */ ...args) => { return (payload, ...args) => {
var getters = /** @type {Getters<Params>} */ (args.map((value) => () => value)); var getters = /** @type {Getters<Params>} */ (args.map((value) => () => value));
payload.out += fn(...getters) /** @type {Payload} */ (/** @type {unknown} */ (payload)).out += fn(...getters)
.render() .render()
.trim(); .trim();
}); };
} }

@ -555,7 +555,6 @@ export { push_element, pop_element } from './dev.js';
export { snapshot } from '../shared/clone.js'; export { snapshot } from '../shared/clone.js';
export { export {
add_snippet_symbol,
validate_component, validate_component,
validate_dynamic_element_tag, validate_dynamic_element_tag,
validate_snippet, validate_snippet,

@ -4,27 +4,13 @@ import { is_void } from '../../constants.js';
import * as w from './warnings.js'; import * as w from './warnings.js';
import * as e from './errors.js'; import * as e from './errors.js';
const snippet_symbol = Symbol.for('svelte.snippet');
/**
* @param {any} fn
* @returns {import('svelte').Snippet}
*/
export function add_snippet_symbol(fn) {
fn[snippet_symbol] = true;
return fn;
}
/** /**
* Validate that the function handed to `{@render ...}` is a snippet function, and not some other kind of function. * Validate that the function handed to `{@render ...}` is a snippet function, and not some other kind of function.
* @param {any} snippet_fn * @param {any} snippet_fn
* @param {Record<string, any> | undefined} $$props Only passed if render tag receives arguments and is for the children prop * @param {Record<string, any> | undefined} $$props Only passed if render tag receives arguments and is for the children prop
*/ */
export function validate_snippet(snippet_fn, $$props) { export function validate_snippet(snippet_fn, $$props) {
if ( if ($$props?.$$slots?.default && typeof $$props.$$slots.default !== 'boolean') {
($$props?.$$slots?.default && typeof $$props.$$slots.default !== 'boolean') ||
(snippet_fn && snippet_fn[snippet_symbol] !== true)
) {
e.render_tag_invalid_argument(); e.render_tag_invalid_argument();
} }
@ -36,11 +22,7 @@ export function validate_snippet(snippet_fn, $$props) {
* @param {any} component_fn * @param {any} component_fn
*/ */
export function validate_component(component_fn) { export function validate_component(component_fn) {
if (component_fn?.[snippet_symbol] === true) { return component_fn; // TODO get rid
e.snippet_used_as_component();
}
return component_fn;
} }
/** /**

@ -1,12 +0,0 @@
import { test } from '../../test';
export default test({
compileOptions: {
dev: true
},
async test({ assert, target }) {
const div = target.querySelector('div');
assert.htmlEqual(div?.innerHTML || '', '');
},
runtime_error: 'snippet_used_as_component\nA snippet must be rendered with `{@render ...}`'
});

@ -1,14 +0,0 @@
<script>
import { onMount, mount } from 'svelte';
let el;
onMount(() => {
mount(foo, { target: el });
});
</script>
<div bind:this={el}></div>
{#snippet foo()}
shouldnt be rendered
{/snippet}

@ -1,8 +0,0 @@
import { test } from '../../test';
export default test({
compileOptions: {
dev: true
},
error: 'render_tag_invalid_argument'
});

@ -1,7 +0,0 @@
<script>
function not_a_snippet() {
console.log('hello');
}
</script>
{@render not_a_snippet()}

@ -1,8 +0,0 @@
import { test } from '../../test';
export default test({
compileOptions: {
dev: true
},
error: 'snippet_used_as_component\nA snippet must be rendered with `{@render ...}`'
});
Loading…
Cancel
Save