feat: allow arbitrary call expressions for render tags

closes #9582
pull/10656/head
Simon Holthausen 3 years ago
parent 3fe4940a9d
commit 2c4efbf85c

@ -0,0 +1,5 @@
---
"svelte": patch
---
feat: allow arbitrary call expressions and optional chaining for snippets

@ -89,7 +89,7 @@ const parse = {
'duplicate-style-element': () => `A component can have a single top-level <style> element`, 'duplicate-style-element': () => `A component can have a single top-level <style> element`,
'duplicate-script-element': () => 'duplicate-script-element': () =>
`A component can have a single top-level <script> element and/or a single top-level <script context="module"> element`, `A component can have a single top-level <script> element and/or a single top-level <script context="module"> element`,
'invalid-render-expression': () => 'expected an identifier followed by (...)', 'invalid-render-expression': () => '{@render ...} tags can only contain call expressions',
'invalid-render-arguments': () => 'expected at most one argument', 'invalid-render-arguments': () => 'expected at most one argument',
'invalid-render-spread-argument': () => 'cannot use spread arguments in {@render ...} tags', 'invalid-render-spread-argument': () => 'cannot use spread arguments in {@render ...} tags',
'invalid-snippet-rest-parameter': () => 'invalid-snippet-rest-parameter': () =>

@ -577,7 +577,12 @@ function special(parser) {
const expression = read_expression(parser); const expression = read_expression(parser);
if (expression.type !== 'CallExpression' || expression.callee.type !== 'Identifier') { if (
expression.type !== 'CallExpression' &&
(expression.type !== 'ChainExpression' ||
expression.expression.type !== 'CallExpression' ||
!expression.expression.optional)
) {
error(expression, 'invalid-render-expression'); error(expression, 'invalid-render-expression');
} }
@ -589,8 +594,7 @@ function special(parser) {
type: 'RenderTag', type: 'RenderTag',
start, start,
end: parser.index, end: parser.index,
expression: expression.callee, expression: expression
arguments: expression.arguments
}); });
} }
} }

@ -604,11 +604,16 @@ const validation = {
}); });
}, },
RenderTag(node, context) { RenderTag(node, context) {
for (const arg of node.arguments) { const raw_args =
node.expression.type === 'CallExpression'
? node.expression.arguments
: node.expression.expression.arguments;
for (const arg of raw_args) {
if (arg.type === 'SpreadElement') { if (arg.type === 'SpreadElement') {
error(arg, 'invalid-render-spread-argument'); error(arg, 'invalid-render-spread-argument');
} }
} }
const is_inside_textarea = context.path.find((n) => { const is_inside_textarea = context.path.find((n) => {
return ( return (
n.type === 'SvelteElement' && n.type === 'SvelteElement' &&
@ -622,7 +627,7 @@ const validation = {
node, node,
'invalid-tag-placement', 'invalid-tag-placement',
'inside <textarea> or <svelte:element this="textarea">', 'inside <textarea> or <svelte:element this="textarea">',
node.expression.name 'render'
); );
} }
}, },

@ -1864,18 +1864,24 @@ export const template_visitors = {
}, },
RenderTag(node, context) { RenderTag(node, context) {
context.state.template.push('<!>'); context.state.template.push('<!>');
const binding = context.state.scope.get(node.expression.name); const callee =
const is_reactive = binding?.kind !== 'normal' || node.expression.type !== 'Identifier'; node.expression.type === 'CallExpression'
? node.expression.callee
: node.expression.expression.callee;
const raw_args =
node.expression.type === 'CallExpression'
? node.expression.arguments
: node.expression.expression.arguments;
const is_reactive =
callee.type !== 'Identifier' || context.state.scope.get(callee.name)?.kind !== 'normal';
/** @type {import('estree').Expression[]} */ /** @type {import('estree').Expression[]} */
const args = [context.state.node]; const args = [context.state.node];
for (const arg of node.arguments) { for (const arg of raw_args) {
args.push(b.thunk(/** @type {import('estree').Expression} */ (context.visit(arg)))); args.push(b.thunk(/** @type {import('estree').Expression} */ (context.visit(arg))));
} }
let snippet_function = /** @type {import('estree').Expression} */ ( let snippet_function = /** @type {import('estree').Expression} */ (context.visit(callee));
context.visit(node.expression)
);
if (context.state.options.dev) { if (context.state.options.dev) {
snippet_function = b.call('$.validate_snippet', snippet_function); snippet_function = b.call('$.validate_snippet', snippet_function);
} }
@ -1885,7 +1891,14 @@ export const template_visitors = {
b.stmt(b.call('$.snippet_effect', b.thunk(snippet_function), ...args)) b.stmt(b.call('$.snippet_effect', b.thunk(snippet_function), ...args))
); );
} else { } else {
context.state.after_update.push(b.stmt(b.call(snippet_function, ...args))); context.state.after_update.push(
b.stmt(
(node.expression.type === 'CallExpression' ? b.call : b.maybe_call)(
snippet_function,
...args
)
)
);
} }
}, },
AnimateDirective(node, { state, visit }) { AnimateDirective(node, { state, visit }) {

@ -1141,17 +1141,34 @@ const template_visitors = {
state.init.push(anchor); state.init.push(anchor);
state.template.push(t_expression(anchor_id)); state.template.push(t_expression(anchor_id));
const expression = /** @type {import('estree').Expression} */ (context.visit(node.expression)); const callee =
node.expression.type === 'CallExpression'
? node.expression.callee
: node.expression.expression.callee;
const raw_args =
node.expression.type === 'CallExpression'
? node.expression.arguments
: node.expression.expression.arguments;
const expression = /** @type {import('estree').Expression} */ (context.visit(callee));
const snippet_function = state.options.dev const snippet_function = state.options.dev
? b.call('$.validate_snippet', expression) ? b.call('$.validate_snippet', expression)
: expression; : expression;
const snippet_args = node.arguments.map((arg) => { const snippet_args = raw_args.map((arg) => {
return /** @type {import('estree').Expression} */ (context.visit(arg)); return /** @type {import('estree').Expression} */ (context.visit(arg));
}); });
state.template.push( state.template.push(
t_statement(b.stmt(b.call(snippet_function, b.id('$$payload'), ...snippet_args))) t_statement(
b.stmt(
(node.expression.type === 'CallExpression' ? b.call : b.maybe_call)(
snippet_function,
b.id('$$payload'),
...snippet_args
)
)
)
); );
state.template.push(t_expression(anchor_id)); state.template.push(t_expression(anchor_id));

@ -13,7 +13,10 @@ import type {
ObjectExpression, ObjectExpression,
Pattern, Pattern,
Program, Program,
SpreadElement SpreadElement,
CallExpression,
ChainExpression,
SimpleCallExpression
} from 'estree'; } from 'estree';
import type { Atrule, Rule } from './css'; import type { Atrule, Rule } from './css';
@ -151,8 +154,7 @@ export interface DebugTag extends BaseNode {
/** A `{@render foo(...)} tag */ /** A `{@render foo(...)} tag */
export interface RenderTag extends BaseNode { export interface RenderTag extends BaseNode {
type: 'RenderTag'; type: 'RenderTag';
expression: Identifier; expression: SimpleCallExpression | (ChainExpression & { expression: SimpleCallExpression });
arguments: Array<Expression | SpreadElement>;
} }
type Tag = ExpressionTag | HtmlTag | ConstTag | DebugTag | RenderTag; type Tag = ExpressionTag | HtmlTag | ConstTag | DebugTag | RenderTag;

@ -2684,7 +2684,7 @@ export function sanitize_slots(props) {
} }
/** /**
* @param {() => Function} get_snippet * @param {() => Function | null | undefined} get_snippet
* @param {Node} node * @param {Node} node
* @param {(() => any)[]} args * @param {(() => any)[]} args
* @returns {void} * @returns {void}
@ -2695,7 +2695,9 @@ export function snippet_effect(get_snippet, node, ...args) {
// Only rerender when the snippet function itself changes, // Only rerender when the snippet function itself changes,
// not when an eagerly-read prop inside the snippet function changes // not when an eagerly-read prop inside the snippet function changes
const snippet = get_snippet(); const snippet = get_snippet();
untrack(() => snippet(node, ...args)); if (snippet) {
untrack(() => snippet(node, ...args));
}
return () => { return () => {
if (block.d !== null) { if (block.d !== null) {
remove(block.d); remove(block.d);

@ -118,7 +118,7 @@ export function add_snippet_symbol(fn) {
* @param {any} snippet_fn * @param {any} snippet_fn
*/ */
export function validate_snippet(snippet_fn) { export function validate_snippet(snippet_fn) {
if (snippet_fn[snippet_symbol] !== true) { if (snippet_fn && snippet_fn[snippet_symbol] !== true) {
throw new Error( throw new Error(
'The argument to `{@render ...}` must be a snippet function, not a component or some other kind of function. ' + 'The argument to `{@render ...}` must be a snippet function, not a component or some other kind of function. ' +
'If you want to dynamically render one snippet or another, use `$derived` and pass its result to `{@render ...}`.' 'If you want to dynamically render one snippet or another, use `$derived` and pass its result to `{@render ...}`.'

@ -131,9 +131,9 @@
"start": 83, "start": 83,
"end": 101, "end": 101,
"expression": { "expression": {
"type": "Identifier", "type": "CallExpression",
"start": 92, "start": 92,
"end": 95, "end": 100,
"loc": { "loc": {
"start": { "start": {
"line": 7, "line": 7,
@ -141,29 +141,45 @@
}, },
"end": { "end": {
"line": 7, "line": 7,
"column": 12 "column": 17
} }
}, },
"name": "foo" "callee": {
},
"arguments": [
{
"type": "Identifier", "type": "Identifier",
"start": 96, "start": 92,
"end": 99, "end": 95,
"loc": { "loc": {
"start": { "start": {
"line": 7, "line": 7,
"column": 13 "column": 9
}, },
"end": { "end": {
"line": 7, "line": 7,
"column": 16 "column": 12
} }
}, },
"name": "msg" "name": "foo"
} },
] "arguments": [
{
"type": "Identifier",
"start": 96,
"end": 99,
"loc": {
"start": {
"line": 7,
"column": 13
},
"end": {
"line": 7,
"column": 16
}
},
"name": "msg"
}
],
"optional": false
}
} }
], ],
"transparent": false "transparent": false

@ -0,0 +1,23 @@
import { test } from '../../test';
export default test({
html: `
<p>foo</p>
<hr>
<button>toggle</button>
`,
async test({ assert, target }) {
const btn = target.querySelector('button');
await btn?.click();
assert.htmlEqual(
target.innerHTML,
`
<p>bar</p>
<hr>
<p>foo</p>
<button>toggle</button>
`
);
}
});

@ -0,0 +1,7 @@
<script>
let { snippets, snippet, optional } = $props();
</script>
{@render snippets[snippet]()}
<hr>
{@render optional?.()}

@ -0,0 +1,17 @@
<script>
import Child from './child.svelte';
let snippet = $state(0);
let show = $state(false);
</script>
{#snippet foo()}
<p>foo</p>
{/snippet}
{#snippet bar()}
<p>bar</p>
{/snippet}
<Child snippets={[foo, bar]} {snippet} optional={show ? foo : undefined} />
<button on:click={() => { snippet = 1; show = true; }}>toggle</button>

@ -475,7 +475,7 @@ declare module 'svelte/animate' {
} }
declare module 'svelte/compiler' { declare module 'svelte/compiler' {
import type { AssignmentExpression, ClassDeclaration, Expression, FunctionDeclaration, Identifier, ImportDeclaration, ArrayExpression, MemberExpression, ObjectExpression, Pattern, ArrowFunctionExpression, VariableDeclaration, VariableDeclarator, FunctionExpression, Node, Program, SpreadElement } from 'estree'; import type { AssignmentExpression, ClassDeclaration, Expression, FunctionDeclaration, Identifier, ImportDeclaration, ArrayExpression, MemberExpression, ObjectExpression, Pattern, ArrowFunctionExpression, VariableDeclaration, VariableDeclarator, FunctionExpression, Node, Program, ChainExpression, SimpleCallExpression } from 'estree';
import type { Location } from 'locate-character'; import type { Location } from 'locate-character';
import type { SourceMap } from 'magic-string'; import type { SourceMap } from 'magic-string';
import type { Context } from 'zimmerframe'; import type { Context } from 'zimmerframe';
@ -1197,8 +1197,7 @@ declare module 'svelte/compiler' {
/** A `{@render foo(...)} tag */ /** A `{@render foo(...)} tag */
interface RenderTag extends BaseNode { interface RenderTag extends BaseNode {
type: 'RenderTag'; type: 'RenderTag';
expression: Identifier; expression: SimpleCallExpression | (ChainExpression & { expression: SimpleCallExpression });
arguments: Array<Expression | SpreadElement>;
} }
type Tag = ExpressionTag | HtmlTag | ConstTag | DebugTag | RenderTag; type Tag = ExpressionTag | HtmlTag | ConstTag | DebugTag | RenderTag;

Loading…
Cancel
Save