Skip to content

Commit 4d2f2fb

Browse files
trueadmRich-Harrisdummdidumm
authored
feat: allow snippets to be exported from module scripts (#14315)
* feat: allow snippets to be exported from module scripts * tweak type * fix issue + add test * refactor * refactor * fix exports error * fix lint * fix lint * error on undefined export * hoisted snippets belong in transform state, not analysis * put the code where it's used * drop the local_. just binding. it's cleaner * simplify * simplify * simplify * simplify * tidy up * oops * update message, add some details * lint * Apply suggestions from code review * add some docs * Update packages/svelte/src/compiler/phases/3-transform/utils.js * Update .changeset/famous-parents-turn.md --------- Co-authored-by: Rich Harris <[email protected]> Co-authored-by: Simon H <[email protected]>
1 parent 2e57612 commit 4d2f2fb

File tree

27 files changed

+292
-32
lines changed

27 files changed

+292
-32
lines changed

.changeset/famous-parents-turn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': minor
3+
---
4+
5+
feat: allow snippets to be exported from module scripts

documentation/docs/03-template-syntax/06-snippet.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,20 @@ We can tighten things up further by declaring a generic, so that `data` and `row
246246
</script>
247247
```
248248

249+
## Exporting snippets
250+
251+
Snippets declared at the top level of a `.svelte` file can be exported from a `<script module>` for use in other components, provided they don't reference any declarations in a non-module `<script>` (whether directly or indirectly, via other snippets) ([demo](/playground/untitled#H4sIAAAAAAAAE3WPwY7CMAxEf8UyB1hRgdhjl13Bga8gHFJipEqtGyUGFUX5dxJUtEB3b9bYM_MckHVLWOKut50TMuC5tpbEY4GnuiGP5T6gXG0-ykLSB8vW2oW_UCNZq7Snv_Rjx0Kc4kpc-6OrrfwoVlK3uQ4CaGMgwsl1LUwXy0f54J9-KV4vf20cNo7YkMu22aqAz4-oOLUI9YKluDPF4h_at-hX5PFyzA1tZ84N3fGpf8YfUU6GvDumLqDKmEqCjjCHUEX4hqDTWCU5PJ6Or38c4g1cPu9tnAEAAA==)):
252+
253+
```svelte
254+
<script module>
255+
export { add };
256+
</script>
257+
258+
{#snippet add(a, b)}
259+
{a} + {b} = {a + b}
260+
{/snippet}
261+
```
262+
249263
## Programmatic snippets
250264

251265
Snippets can be created programmatically with the [`createRawSnippet`](svelte#createRawSnippet) API. This is intended for advanced use cases.

documentation/docs/98-reference/.generated/compile-errors.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,12 @@ Expected token %token%
400400
Expected whitespace
401401
```
402402

403+
### export_undefined
404+
405+
```
406+
`%name%` is not defined
407+
```
408+
403409
### global_reference_invalid
404410

405411
```
@@ -694,6 +700,30 @@ Cannot use `<slot>` syntax and `{@render ...}` tags in the same component. Migra
694700
Cannot use explicit children snippet at the same time as implicit children content. Remove either the non-whitespace content or the children snippet block
695701
```
696702

703+
### snippet_invalid_export
704+
705+
```
706+
An exported snippet can only reference things declared in a `<script module>`, or other exportable snippets
707+
```
708+
709+
It's possible to export a snippet from a `<script module>` block, but only if it doesn't reference anything defined inside a non-module-level `<script>`. For example you can't do this...
710+
711+
```svelte
712+
<script module>
713+
export { greeting };
714+
</script>
715+
716+
<script>
717+
let message = 'hello';
718+
</script>
719+
720+
{#snippet greeting(name)}
721+
<p>{message} {name}!</p>
722+
{/snippet}
723+
```
724+
725+
...because `greeting` references `message`, which is defined in the second `<script>`.
726+
697727
### snippet_invalid_rest_parameter
698728

699729
```

packages/svelte/messages/compile-errors/script.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@
3838

3939
> `$effect()` can only be used as an expression statement
4040
41+
## export_undefined
42+
43+
> `%name%` is not defined
44+
4145
## global_reference_invalid
4246

4347
> `%name%` is an illegal variable name. To reference a global variable called `%name%`, use `globalThis.%name%`
@@ -134,6 +138,28 @@
134138

135139
> %name% cannot be used in runes mode
136140
141+
## snippet_invalid_export
142+
143+
> An exported snippet can only reference things declared in a `<script module>`, or other exportable snippets
144+
145+
It's possible to export a snippet from a `<script module>` block, but only if it doesn't reference anything defined inside a non-module-level `<script>`. For example you can't do this...
146+
147+
```svelte
148+
<script module>
149+
export { greeting };
150+
</script>
151+
152+
<script>
153+
let message = 'hello';
154+
</script>
155+
156+
{#snippet greeting(name)}
157+
<p>{message} {name}!</p>
158+
{/snippet}
159+
```
160+
161+
...because `greeting` references `message`, which is defined in the second `<script>`.
162+
137163
## snippet_parameter_assignment
138164

139165
> Cannot reassign or bind to snippet parameter

packages/svelte/src/compiler/errors.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,16 @@ export function effect_invalid_placement(node) {
168168
e(node, "effect_invalid_placement", "`$effect()` can only be used as an expression statement");
169169
}
170170

171+
/**
172+
* `%name%` is not defined
173+
* @param {null | number | NodeLike} node
174+
* @param {string} name
175+
* @returns {never}
176+
*/
177+
export function export_undefined(node, name) {
178+
e(node, "export_undefined", `\`${name}\` is not defined`);
179+
}
180+
171181
/**
172182
* `%name%` is an illegal variable name. To reference a global variable called `%name%`, use `globalThis.%name%`
173183
* @param {null | number | NodeLike} node
@@ -395,6 +405,15 @@ export function runes_mode_invalid_import(node, name) {
395405
e(node, "runes_mode_invalid_import", `${name} cannot be used in runes mode`);
396406
}
397407

408+
/**
409+
* An exported snippet can only reference things declared in a `<script module>`, or other exportable snippets
410+
* @param {null | number | NodeLike} node
411+
* @returns {never}
412+
*/
413+
export function snippet_invalid_export(node) {
414+
e(node, "snippet_invalid_export", "An exported snippet can only reference things declared in a `<script module>`, or other exportable snippets");
415+
}
416+
398417
/**
399418
* Cannot reassign or bind to snippet parameter
400419
* @param {null | number | NodeLike} node

packages/svelte/src/compiler/phases/1-parse/acorn.js

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,43 @@ const ParserWithTS = acorn.Parser.extend(tsPlugin({ allowSatisfies: true }));
1010
/**
1111
* @param {string} source
1212
* @param {boolean} typescript
13+
* @param {boolean} [is_script]
1314
*/
14-
export function parse(source, typescript) {
15+
export function parse(source, typescript, is_script) {
1516
const parser = typescript ? ParserWithTS : acorn.Parser;
1617
const { onComment, add_comments } = get_comment_handlers(source);
17-
18-
const ast = parser.parse(source, {
19-
onComment,
20-
sourceType: 'module',
21-
ecmaVersion: 13,
22-
locations: true
23-
});
18+
// @ts-ignore
19+
const parse_statement = parser.prototype.parseStatement;
20+
21+
// If we're dealing with a <script> then it might contain an export
22+
// for something that doesn't exist directly inside but is inside the
23+
// component instead, so we need to ensure that Acorn doesn't throw
24+
// an error in these cases
25+
if (is_script) {
26+
// @ts-ignore
27+
parser.prototype.parseStatement = function (...args) {
28+
const v = parse_statement.call(this, ...args);
29+
// @ts-ignore
30+
this.undefinedExports = {};
31+
return v;
32+
};
33+
}
34+
35+
let ast;
36+
37+
try {
38+
ast = parser.parse(source, {
39+
onComment,
40+
sourceType: 'module',
41+
ecmaVersion: 13,
42+
locations: true
43+
});
44+
} finally {
45+
if (is_script) {
46+
// @ts-ignore
47+
parser.prototype.parseStatement = parse_statement;
48+
}
49+
}
2450

2551
if (typescript) amend(source, ast);
2652
add_comments(ast);

packages/svelte/src/compiler/phases/1-parse/read/script.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export function read_script(parser, start, attributes) {
3434
let ast;
3535

3636
try {
37-
ast = acorn.parse(source, parser.ts);
37+
ast = acorn.parse(source, parser.ts, true);
3838
} catch (err) {
3939
parser.acorn_error(err);
4040
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ function open(parser) {
325325
parameters: function_expression.params,
326326
body: create_fragment(),
327327
metadata: {
328+
can_hoist: false,
328329
sites: new Set()
329330
}
330331
});

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,6 @@ export function analyze_component(root, source, options) {
429429
reactive_statements: new Map(),
430430
binding_groups: new Map(),
431431
slot_names: new Map(),
432-
top_level_snippets: [],
433432
css: {
434433
ast: root.css,
435434
hash: root.css
@@ -443,6 +442,7 @@ export function analyze_component(root, source, options) {
443442
keyframes: []
444443
},
445444
source,
445+
undefined_exports: new Map(),
446446
snippet_renderers: new Map(),
447447
snippets: new Set()
448448
};
@@ -697,6 +697,17 @@ export function analyze_component(root, source, options) {
697697
analysis.reactive_statements = order_reactive_statements(analysis.reactive_statements);
698698
}
699699

700+
for (const node of analysis.module.ast.body) {
701+
if (node.type === 'ExportNamedDeclaration' && node.specifiers !== null) {
702+
for (const specifier of node.specifiers) {
703+
if (specifier.local.type !== 'Identifier') continue;
704+
705+
const binding = analysis.module.scope.get(specifier.local.name);
706+
if (!binding) e.export_undefined(specifier, specifier.local.name);
707+
}
708+
}
709+
}
710+
700711
if (analysis.event_directive_node && analysis.uses_event_attributes) {
701712
e.mixed_event_handler_syntaxes(
702713
analysis.event_directive_node,

packages/svelte/src/compiler/phases/2-analyze/visitors/SnippetBlock.js

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
/** @import { AST } from '#compiler' */
1+
/** @import { AST, Binding, SvelteNode } from '#compiler' */
2+
/** @import { Scope } from '../../scope' */
23
/** @import { Context } from '../types' */
34
import { validate_block_not_empty, validate_opening_tag } from './shared/utils.js';
45
import * as e from '../../../errors.js';
@@ -24,6 +25,25 @@ export function SnippetBlock(node, context) {
2425

2526
context.next({ ...context.state, parent_element: null });
2627

28+
const can_hoist =
29+
context.path.length === 1 &&
30+
context.path[0].type === 'Fragment' &&
31+
can_hoist_snippet(context.state.scope, context.state.scopes);
32+
33+
const name = node.expression.name;
34+
35+
if (can_hoist) {
36+
const binding = /** @type {Binding} */ (context.state.scope.get(name));
37+
context.state.analysis.module.scope.declarations.set(name, binding);
38+
} else {
39+
const undefined_export = context.state.analysis.undefined_exports.get(name);
40+
if (undefined_export) {
41+
e.snippet_invalid_export(undefined_export);
42+
}
43+
}
44+
45+
node.metadata.can_hoist = can_hoist;
46+
2747
const { path } = context;
2848
const parent = path.at(-2);
2949
if (!parent) return;
@@ -58,3 +78,35 @@ export function SnippetBlock(node, context) {
5878
}
5979
}
6080
}
81+
82+
/**
83+
* @param {Map<SvelteNode, Scope>} scopes
84+
* @param {Scope} scope
85+
*/
86+
function can_hoist_snippet(scope, scopes, visited = new Set()) {
87+
for (const [reference] of scope.references) {
88+
const binding = scope.get(reference);
89+
90+
if (!binding || binding.scope.function_depth === 0) {
91+
continue;
92+
}
93+
94+
// ignore bindings declared inside the snippet (e.g. the snippet's own parameters)
95+
if (binding.scope.function_depth >= scope.function_depth) {
96+
continue;
97+
}
98+
99+
if (binding.initial?.type === 'SnippetBlock') {
100+
if (visited.has(binding)) continue;
101+
visited.add(binding);
102+
103+
if (can_hoist_snippet(binding.scope, scopes, visited)) {
104+
continue;
105+
}
106+
}
107+
108+
return false;
109+
}
110+
111+
return true;
112+
}

0 commit comments

Comments
 (0)