Skip to content

Commit 56315df

Browse files
authored
feat: allow arbitrary call expressions for render tags (#10656)
closes #9582
1 parent b2e9be2 commit 56315df

File tree

17 files changed

+206
-42
lines changed

17 files changed

+206
-42
lines changed

.changeset/ten-jokes-divide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte": patch
3+
---
4+
5+
feat: allow arbitrary call expressions and optional chaining for snippets

packages/svelte/src/compiler/errors.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,10 @@ const parse = {
8989
'duplicate-style-element': () => `A component can have a single top-level <style> element`,
9090
'duplicate-script-element': () =>
9191
`A component can have a single top-level <script> element and/or a single top-level <script context="module"> element`,
92-
'invalid-render-expression': () => 'expected an identifier followed by (...)',
92+
'invalid-render-expression': () => '{@render ...} tags can only contain call expressions',
9393
'invalid-render-arguments': () => 'expected at most one argument',
94+
'invalid-render-call': () =>
95+
'Calling a snippet function using apply, bind or call is not allowed',
9496
'invalid-render-spread-argument': () => 'cannot use spread arguments in {@render ...} tags',
9597
'invalid-snippet-rest-parameter': () =>
9698
'snippets do not support rest parameters; use an array instead'

packages/svelte/src/compiler/phases/1-parse/state/tag.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -577,7 +577,12 @@ function special(parser) {
577577

578578
const expression = read_expression(parser);
579579

580-
if (expression.type !== 'CallExpression' || expression.callee.type !== 'Identifier') {
580+
if (
581+
expression.type !== 'CallExpression' &&
582+
(expression.type !== 'ChainExpression' ||
583+
expression.expression.type !== 'CallExpression' ||
584+
!expression.expression.optional)
585+
) {
581586
error(expression, 'invalid-render-expression');
582587
}
583588

@@ -589,8 +594,7 @@ function special(parser) {
589594
type: 'RenderTag',
590595
start,
591596
end: parser.index,
592-
expression: expression.callee,
593-
arguments: expression.arguments
597+
expression: expression
594598
});
595599
}
596600
}

packages/svelte/src/compiler/phases/2-analyze/validation.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import {
99
get_parent,
1010
is_expression_attribute,
1111
is_text_attribute,
12-
object
12+
object,
13+
unwrap_optional
1314
} from '../../utils/ast.js';
1415
import { warn } from '../../warnings.js';
1516
import fuzzymatch from '../1-parse/utils/fuzzymatch.js';
@@ -487,11 +488,22 @@ const validation = {
487488
});
488489
},
489490
RenderTag(node, context) {
490-
for (const arg of node.arguments) {
491+
const raw_args = unwrap_optional(node.expression).arguments;
492+
for (const arg of raw_args) {
491493
if (arg.type === 'SpreadElement') {
492494
error(arg, 'invalid-render-spread-argument');
493495
}
494496
}
497+
498+
const callee = unwrap_optional(node.expression).callee;
499+
if (
500+
callee.type === 'MemberExpression' &&
501+
callee.property.type === 'Identifier' &&
502+
['bind', 'apply', 'call'].includes(callee.property.name)
503+
) {
504+
error(node, 'invalid-render-call');
505+
}
506+
495507
const is_inside_textarea = context.path.find((n) => {
496508
return (
497509
n.type === 'SvelteElement' &&
@@ -505,7 +517,7 @@ const validation = {
505517
node,
506518
'invalid-tag-placement',
507519
'inside <textarea> or <svelte:element this="textarea">',
508-
node.expression.name
520+
'render'
509521
);
510522
}
511523
},

packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import {
33
extract_paths,
44
is_event_attribute,
55
is_text_attribute,
6-
object
6+
object,
7+
unwrap_optional
78
} from '../../../../utils/ast.js';
89
import { binding_properties } from '../../../bindings.js';
910
import {
@@ -1864,18 +1865,18 @@ export const template_visitors = {
18641865
},
18651866
RenderTag(node, context) {
18661867
context.state.template.push('<!>');
1867-
const binding = context.state.scope.get(node.expression.name);
1868-
const is_reactive = binding?.kind !== 'normal' || node.expression.type !== 'Identifier';
1868+
const callee = unwrap_optional(node.expression).callee;
1869+
const raw_args = unwrap_optional(node.expression).arguments;
1870+
const is_reactive =
1871+
callee.type !== 'Identifier' || context.state.scope.get(callee.name)?.kind !== 'normal';
18691872

18701873
/** @type {import('estree').Expression[]} */
18711874
const args = [context.state.node];
1872-
for (const arg of node.arguments) {
1875+
for (const arg of raw_args) {
18731876
args.push(b.thunk(/** @type {import('estree').Expression} */ (context.visit(arg))));
18741877
}
18751878

1876-
let snippet_function = /** @type {import('estree').Expression} */ (
1877-
context.visit(node.expression)
1878-
);
1879+
let snippet_function = /** @type {import('estree').Expression} */ (context.visit(callee));
18791880
if (context.state.options.dev) {
18801881
snippet_function = b.call('$.validate_snippet', snippet_function);
18811882
}
@@ -1885,7 +1886,14 @@ export const template_visitors = {
18851886
b.stmt(b.call('$.snippet_effect', b.thunk(snippet_function), ...args))
18861887
);
18871888
} else {
1888-
context.state.after_update.push(b.stmt(b.call(snippet_function, ...args)));
1889+
context.state.after_update.push(
1890+
b.stmt(
1891+
(node.expression.type === 'CallExpression' ? b.call : b.maybe_call)(
1892+
snippet_function,
1893+
...args
1894+
)
1895+
)
1896+
);
18891897
}
18901898
},
18911899
AnimateDirective(node, { state, visit }) {

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

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { walk } from 'zimmerframe';
22
import { set_scope, get_rune } from '../../scope.js';
3-
import { extract_identifiers, extract_paths, is_event_attribute } from '../../../utils/ast.js';
3+
import {
4+
extract_identifiers,
5+
extract_paths,
6+
is_event_attribute,
7+
unwrap_optional
8+
} from '../../../utils/ast.js';
49
import * as b from '../../../utils/builders.js';
510
import is_reference from 'is-reference';
611
import {
@@ -1161,17 +1166,28 @@ const template_visitors = {
11611166
state.init.push(anchor);
11621167
state.template.push(t_expression(anchor_id));
11631168

1164-
const expression = /** @type {import('estree').Expression} */ (context.visit(node.expression));
1169+
const callee = unwrap_optional(node.expression).callee;
1170+
const raw_args = unwrap_optional(node.expression).arguments;
1171+
1172+
const expression = /** @type {import('estree').Expression} */ (context.visit(callee));
11651173
const snippet_function = state.options.dev
11661174
? b.call('$.validate_snippet', expression)
11671175
: expression;
11681176

1169-
const snippet_args = node.arguments.map((arg) => {
1177+
const snippet_args = raw_args.map((arg) => {
11701178
return /** @type {import('estree').Expression} */ (context.visit(arg));
11711179
});
11721180

11731181
state.template.push(
1174-
t_statement(b.stmt(b.call(snippet_function, b.id('$$payload'), ...snippet_args)))
1182+
t_statement(
1183+
b.stmt(
1184+
(node.expression.type === 'CallExpression' ? b.call : b.maybe_call)(
1185+
snippet_function,
1186+
b.id('$$payload'),
1187+
...snippet_args
1188+
)
1189+
)
1190+
)
11751191
);
11761192

11771193
state.template.push(t_expression(anchor_id));

packages/svelte/src/compiler/types/template.d.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import type {
1313
ObjectExpression,
1414
Pattern,
1515
Program,
16-
SpreadElement
16+
SpreadElement,
17+
CallExpression,
18+
ChainExpression,
19+
SimpleCallExpression
1720
} from 'estree';
1821
import type { Atrule, Rule } from './css';
1922

@@ -151,8 +154,7 @@ export interface DebugTag extends BaseNode {
151154
/** A `{@render foo(...)} tag */
152155
export interface RenderTag extends BaseNode {
153156
type: 'RenderTag';
154-
expression: Identifier;
155-
arguments: Array<Expression | SpreadElement>;
157+
expression: SimpleCallExpression | (ChainExpression & { expression: SimpleCallExpression });
156158
}
157159

158160
type Tag = ExpressionTag | HtmlTag | ConstTag | DebugTag | RenderTag;

packages/svelte/src/compiler/utils/ast.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,3 +361,12 @@ export function is_simple_expression(node) {
361361

362362
return false;
363363
}
364+
365+
/**
366+
* @template {import('estree').SimpleCallExpression | import('estree').MemberExpression} T
367+
* @param {import('estree').ChainExpression & { expression : T } | T} node
368+
* @returns {T}
369+
*/
370+
export function unwrap_optional(node) {
371+
return node.type === 'ChainExpression' ? node.expression : node;
372+
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2684,7 +2684,7 @@ export function sanitize_slots(props) {
26842684
}
26852685

26862686
/**
2687-
* @param {() => Function} get_snippet
2687+
* @param {() => Function | null | undefined} get_snippet
26882688
* @param {Node} node
26892689
* @param {(() => any)[]} args
26902690
* @returns {void}
@@ -2695,7 +2695,9 @@ export function snippet_effect(get_snippet, node, ...args) {
26952695
// Only rerender when the snippet function itself changes,
26962696
// not when an eagerly-read prop inside the snippet function changes
26972697
const snippet = get_snippet();
2698-
untrack(() => snippet(node, ...args));
2698+
if (snippet) {
2699+
untrack(() => snippet(node, ...args));
2700+
}
26992701
return () => {
27002702
if (block.d !== null) {
27012703
remove(block.d);

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export function add_snippet_symbol(fn) {
118118
* @param {any} snippet_fn
119119
*/
120120
export function validate_snippet(snippet_fn) {
121-
if (snippet_fn[snippet_symbol] !== true) {
121+
if (snippet_fn && snippet_fn[snippet_symbol] !== true) {
122122
throw new Error(
123123
'The argument to `{@render ...}` must be a snippet function, not a component or some other kind of function. ' +
124124
'If you want to dynamically render one snippet or another, use `$derived` and pass its result to `{@render ...}`.'
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
error: {
5+
code: 'invalid-render-call',
6+
message: 'Calling a snippet function using apply, bind or call is not allowed'
7+
}
8+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{@render snippet.apply(null, [1, 2, 3])}

packages/svelte/tests/parser-modern/samples/snippets/output.json

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -131,39 +131,55 @@
131131
"start": 83,
132132
"end": 101,
133133
"expression": {
134-
"type": "Identifier",
134+
"type": "CallExpression",
135135
"start": 92,
136-
"end": 95,
136+
"end": 100,
137137
"loc": {
138138
"start": {
139139
"line": 7,
140140
"column": 9
141141
},
142142
"end": {
143143
"line": 7,
144-
"column": 12
144+
"column": 17
145145
}
146146
},
147-
"name": "foo"
148-
},
149-
"arguments": [
150-
{
147+
"callee": {
151148
"type": "Identifier",
152-
"start": 96,
153-
"end": 99,
149+
"start": 92,
150+
"end": 95,
154151
"loc": {
155152
"start": {
156153
"line": 7,
157-
"column": 13
154+
"column": 9
158155
},
159156
"end": {
160157
"line": 7,
161-
"column": 16
158+
"column": 12
162159
}
163160
},
164-
"name": "msg"
165-
}
166-
]
161+
"name": "foo"
162+
},
163+
"arguments": [
164+
{
165+
"type": "Identifier",
166+
"start": 96,
167+
"end": 99,
168+
"loc": {
169+
"start": {
170+
"line": 7,
171+
"column": 13
172+
},
173+
"end": {
174+
"line": 7,
175+
"column": 16
176+
}
177+
},
178+
"name": "msg"
179+
}
180+
],
181+
"optional": false
182+
}
167183
}
168184
],
169185
"transparent": false
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
html: `
5+
<p>foo</p>
6+
<hr>
7+
<p>foo</p>
8+
<hr>
9+
<p>foo</p>
10+
<hr>
11+
<p>foo</p>
12+
<hr>
13+
<p>bar</p>
14+
<hr>
15+
<hr>
16+
<button>toggle</button>
17+
`,
18+
19+
async test({ assert, target }) {
20+
const btn = target.querySelector('button');
21+
await btn?.click();
22+
assert.htmlEqual(
23+
target.innerHTML,
24+
`
25+
<p>bar</p>
26+
<hr>
27+
<p>bar</p>
28+
<hr>
29+
<p>foo</p>
30+
<hr>
31+
<p>foo</p>
32+
<hr>
33+
<p>foo</p>
34+
<hr>
35+
<p>foo</p>
36+
<hr>
37+
<p>foo</p>
38+
<button>toggle</button>
39+
`
40+
);
41+
}
42+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<script>
2+
let { snippets, snippet, optional } = $props();
3+
4+
function getOptional() {
5+
return optional;
6+
}
7+
</script>
8+
9+
{@render snippets[snippet]()}
10+
<hr>
11+
{@render snippets?.[snippet]?.()}
12+
<hr>
13+
{@render snippets.foo()}
14+
<hr>
15+
{@render snippets.foo?.()}
16+
<hr>
17+
{@render (optional ?? snippets.bar)()}
18+
<hr>
19+
{@render optional?.()}
20+
<hr>
21+
{@render getOptional()?.()}

0 commit comments

Comments
 (0)