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 <rich.harris@vercel.com>
Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
pull/14510/head
Dominic Gannaway 3 weeks ago committed by GitHub
parent 2e57612ef4
commit 4d2f2fb8e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: allow snippets to be exported from module scripts

@ -246,6 +246,20 @@ We can tighten things up further by declaring a generic, so that `data` and `row
</script> </script>
``` ```
## Exporting snippets
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==)):
```svelte
<script module>
export { add };
</script>
{#snippet add(a, b)}
{a} + {b} = {a + b}
{/snippet}
```
## Programmatic snippets ## Programmatic snippets
Snippets can be created programmatically with the [`createRawSnippet`](svelte#createRawSnippet) API. This is intended for advanced use cases. Snippets can be created programmatically with the [`createRawSnippet`](svelte#createRawSnippet) API. This is intended for advanced use cases.

@ -400,6 +400,12 @@ Expected token %token%
Expected whitespace Expected whitespace
``` ```
### export_undefined
```
`%name%` is not defined
```
### global_reference_invalid ### global_reference_invalid
``` ```
@ -694,6 +700,30 @@ Cannot use `<slot>` syntax and `{@render ...}` tags in the same component. Migra
Cannot use explicit children snippet at the same time as implicit children content. Remove either the non-whitespace content or the children snippet block Cannot use explicit children snippet at the same time as implicit children content. Remove either the non-whitespace content or the children snippet block
``` ```
### snippet_invalid_export
```
An exported snippet can only reference things declared in a `<script module>`, or other exportable snippets
```
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...
```svelte
<script module>
export { greeting };
</script>
<script>
let message = 'hello';
</script>
{#snippet greeting(name)}
<p>{message} {name}!</p>
{/snippet}
```
...because `greeting` references `message`, which is defined in the second `<script>`.
### snippet_invalid_rest_parameter ### snippet_invalid_rest_parameter
``` ```

@ -38,6 +38,10 @@
> `$effect()` can only be used as an expression statement > `$effect()` can only be used as an expression statement
## export_undefined
> `%name%` is not defined
## global_reference_invalid ## global_reference_invalid
> `%name%` is an illegal variable name. To reference a global variable called `%name%`, use `globalThis.%name%` > `%name%` is an illegal variable name. To reference a global variable called `%name%`, use `globalThis.%name%`
@ -134,6 +138,28 @@
> %name% cannot be used in runes mode > %name% cannot be used in runes mode
## snippet_invalid_export
> An exported snippet can only reference things declared in a `<script module>`, or other exportable snippets
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...
```svelte
<script module>
export { greeting };
</script>
<script>
let message = 'hello';
</script>
{#snippet greeting(name)}
<p>{message} {name}!</p>
{/snippet}
```
...because `greeting` references `message`, which is defined in the second `<script>`.
## snippet_parameter_assignment ## snippet_parameter_assignment
> Cannot reassign or bind to snippet parameter > Cannot reassign or bind to snippet parameter

@ -168,6 +168,16 @@ export function effect_invalid_placement(node) {
e(node, "effect_invalid_placement", "`$effect()` can only be used as an expression statement"); e(node, "effect_invalid_placement", "`$effect()` can only be used as an expression statement");
} }
/**
* `%name%` is not defined
* @param {null | number | NodeLike} node
* @param {string} name
* @returns {never}
*/
export function export_undefined(node, name) {
e(node, "export_undefined", `\`${name}\` is not defined`);
}
/** /**
* `%name%` is an illegal variable name. To reference a global variable called `%name%`, use `globalThis.%name%` * `%name%` is an illegal variable name. To reference a global variable called `%name%`, use `globalThis.%name%`
* @param {null | number | NodeLike} node * @param {null | number | NodeLike} node
@ -395,6 +405,15 @@ export function runes_mode_invalid_import(node, name) {
e(node, "runes_mode_invalid_import", `${name} cannot be used in runes mode`); e(node, "runes_mode_invalid_import", `${name} cannot be used in runes mode`);
} }
/**
* An exported snippet can only reference things declared in a `<script module>`, or other exportable snippets
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function snippet_invalid_export(node) {
e(node, "snippet_invalid_export", "An exported snippet can only reference things declared in a `<script module>`, or other exportable snippets");
}
/** /**
* Cannot reassign or bind to snippet parameter * Cannot reassign or bind to snippet parameter
* @param {null | number | NodeLike} node * @param {null | number | NodeLike} node

@ -10,17 +10,43 @@ const ParserWithTS = acorn.Parser.extend(tsPlugin({ allowSatisfies: true }));
/** /**
* @param {string} source * @param {string} source
* @param {boolean} typescript * @param {boolean} typescript
* @param {boolean} [is_script]
*/ */
export function parse(source, typescript) { export function parse(source, typescript, is_script) {
const parser = typescript ? ParserWithTS : acorn.Parser; const parser = typescript ? ParserWithTS : acorn.Parser;
const { onComment, add_comments } = get_comment_handlers(source); const { onComment, add_comments } = get_comment_handlers(source);
// @ts-ignore
const ast = parser.parse(source, { const parse_statement = parser.prototype.parseStatement;
onComment,
sourceType: 'module', // If we're dealing with a <script> then it might contain an export
ecmaVersion: 13, // for something that doesn't exist directly inside but is inside the
locations: true // component instead, so we need to ensure that Acorn doesn't throw
}); // an error in these cases
if (is_script) {
// @ts-ignore
parser.prototype.parseStatement = function (...args) {
const v = parse_statement.call(this, ...args);
// @ts-ignore
this.undefinedExports = {};
return v;
};
}
let ast;
try {
ast = parser.parse(source, {
onComment,
sourceType: 'module',
ecmaVersion: 13,
locations: true
});
} finally {
if (is_script) {
// @ts-ignore
parser.prototype.parseStatement = parse_statement;
}
}
if (typescript) amend(source, ast); if (typescript) amend(source, ast);
add_comments(ast); add_comments(ast);

@ -34,7 +34,7 @@ export function read_script(parser, start, attributes) {
let ast; let ast;
try { try {
ast = acorn.parse(source, parser.ts); ast = acorn.parse(source, parser.ts, true);
} catch (err) { } catch (err) {
parser.acorn_error(err); parser.acorn_error(err);
} }

@ -325,6 +325,7 @@ function open(parser) {
parameters: function_expression.params, parameters: function_expression.params,
body: create_fragment(), body: create_fragment(),
metadata: { metadata: {
can_hoist: false,
sites: new Set() sites: new Set()
} }
}); });

@ -429,7 +429,6 @@ export function analyze_component(root, source, options) {
reactive_statements: new Map(), reactive_statements: new Map(),
binding_groups: new Map(), binding_groups: new Map(),
slot_names: new Map(), slot_names: new Map(),
top_level_snippets: [],
css: { css: {
ast: root.css, ast: root.css,
hash: root.css hash: root.css
@ -443,6 +442,7 @@ export function analyze_component(root, source, options) {
keyframes: [] keyframes: []
}, },
source, source,
undefined_exports: new Map(),
snippet_renderers: new Map(), snippet_renderers: new Map(),
snippets: new Set() snippets: new Set()
}; };
@ -697,6 +697,17 @@ export function analyze_component(root, source, options) {
analysis.reactive_statements = order_reactive_statements(analysis.reactive_statements); analysis.reactive_statements = order_reactive_statements(analysis.reactive_statements);
} }
for (const node of analysis.module.ast.body) {
if (node.type === 'ExportNamedDeclaration' && node.specifiers !== null) {
for (const specifier of node.specifiers) {
if (specifier.local.type !== 'Identifier') continue;
const binding = analysis.module.scope.get(specifier.local.name);
if (!binding) e.export_undefined(specifier, specifier.local.name);
}
}
}
if (analysis.event_directive_node && analysis.uses_event_attributes) { if (analysis.event_directive_node && analysis.uses_event_attributes) {
e.mixed_event_handler_syntaxes( e.mixed_event_handler_syntaxes(
analysis.event_directive_node, analysis.event_directive_node,

@ -1,4 +1,5 @@
/** @import { AST } from '#compiler' */ /** @import { AST, Binding, SvelteNode } from '#compiler' */
/** @import { Scope } from '../../scope' */
/** @import { Context } from '../types' */ /** @import { Context } from '../types' */
import { validate_block_not_empty, validate_opening_tag } from './shared/utils.js'; import { validate_block_not_empty, validate_opening_tag } from './shared/utils.js';
import * as e from '../../../errors.js'; import * as e from '../../../errors.js';
@ -24,6 +25,25 @@ export function SnippetBlock(node, context) {
context.next({ ...context.state, parent_element: null }); context.next({ ...context.state, parent_element: null });
const can_hoist =
context.path.length === 1 &&
context.path[0].type === 'Fragment' &&
can_hoist_snippet(context.state.scope, context.state.scopes);
const name = node.expression.name;
if (can_hoist) {
const binding = /** @type {Binding} */ (context.state.scope.get(name));
context.state.analysis.module.scope.declarations.set(name, binding);
} else {
const undefined_export = context.state.analysis.undefined_exports.get(name);
if (undefined_export) {
e.snippet_invalid_export(undefined_export);
}
}
node.metadata.can_hoist = can_hoist;
const { path } = context; const { path } = context;
const parent = path.at(-2); const parent = path.at(-2);
if (!parent) return; if (!parent) return;
@ -58,3 +78,35 @@ export function SnippetBlock(node, context) {
} }
} }
} }
/**
* @param {Map<SvelteNode, Scope>} scopes
* @param {Scope} scope
*/
function can_hoist_snippet(scope, scopes, visited = new Set()) {
for (const [reference] of scope.references) {
const binding = scope.get(reference);
if (!binding || binding.scope.function_depth === 0) {
continue;
}
// ignore bindings declared inside the snippet (e.g. the snippet's own parameters)
if (binding.scope.function_depth >= scope.function_depth) {
continue;
}
if (binding.initial?.type === 'SnippetBlock') {
if (visited.has(binding)) continue;
visited.add(binding);
if (can_hoist_snippet(binding.scope, scopes, visited)) {
continue;
}
}
return false;
}
return true;
}

@ -165,6 +165,8 @@ export function client_component(analysis, options) {
private_state: new Map(), private_state: new Map(),
transform: {}, transform: {},
in_constructor: false, in_constructor: false,
instance_level_snippets: [],
module_level_snippets: [],
// these are set inside the `Fragment` visitor, and cannot be used until then // these are set inside the `Fragment` visitor, and cannot be used until then
before_init: /** @type {any} */ (null), before_init: /** @type {any} */ (null),
@ -370,7 +372,7 @@ export function client_component(analysis, options) {
...store_setup, ...store_setup,
...legacy_reactive_declarations, ...legacy_reactive_declarations,
...group_binding_declarations, ...group_binding_declarations,
...analysis.top_level_snippets, ...state.instance_level_snippets,
.../** @type {ESTree.Statement[]} */ (instance.body), .../** @type {ESTree.Statement[]} */ (instance.body),
analysis.runes || !analysis.needs_context analysis.runes || !analysis.needs_context
? b.empty ? b.empty
@ -485,7 +487,7 @@ export function client_component(analysis, options) {
} }
} }
body = [...imports, ...body]; body = [...imports, ...state.module_level_snippets, ...body];
const component = b.function_declaration( const component = b.function_declaration(
b.id(analysis.name), b.id(analysis.name),

@ -6,7 +6,8 @@ import type {
PrivateIdentifier, PrivateIdentifier,
Expression, Expression,
AssignmentExpression, AssignmentExpression,
UpdateExpression UpdateExpression,
VariableDeclaration
} from 'estree'; } from 'estree';
import type { Namespace, SvelteNode, ValidatedCompileOptions } from '#compiler'; import type { Namespace, SvelteNode, ValidatedCompileOptions } from '#compiler';
import type { TransformState } from '../types.js'; import type { TransformState } from '../types.js';
@ -85,6 +86,11 @@ export interface ComponentClientTransformState extends ClientTransformState {
/** The $: calls, which will be ordered in the end */ /** The $: calls, which will be ordered in the end */
readonly legacy_reactive_statements: Map<LabeledStatement, Statement>; readonly legacy_reactive_statements: Map<LabeledStatement, Statement>;
/** Snippets hoisted to the instance */
readonly instance_level_snippets: VariableDeclaration[];
/** Snippets hoisted to the module */
readonly module_level_snippets: VariableDeclaration[];
} }
export interface StateField { export interface StateField {

@ -83,7 +83,11 @@ export function SnippetBlock(node, context) {
// Top-level snippets are hoisted so they can be referenced in the `<script>` // Top-level snippets are hoisted so they can be referenced in the `<script>`
if (context.path.length === 1 && context.path[0].type === 'Fragment') { if (context.path.length === 1 && context.path[0].type === 'Fragment') {
context.state.analysis.top_level_snippets.push(declaration); if (node.metadata.can_hoist) {
context.state.module_level_snippets.push(declaration);
} else {
context.state.instance_level_snippets.push(declaration);
}
} else { } else {
context.state.init.push(declaration); context.state.init.push(declaration);
} }

@ -17,6 +17,9 @@ export function SnippetBlock(node, context) {
// @ts-expect-error - TODO remove this hack once $$render_inner for legacy bindings is gone // @ts-expect-error - TODO remove this hack once $$render_inner for legacy bindings is gone
fn.___snippet = true; fn.___snippet = true;
// TODO hoist where possible if (node.metadata.can_hoist) {
context.state.init.push(fn); context.state.hoisted.push(fn);
} else {
context.state.init.push(fn);
}
} }

@ -1,5 +1,5 @@
import type { AST, Binding, Css, SvelteNode } from '#compiler'; import type { AST, Binding, Css, SvelteNode } from '#compiler';
import type { Identifier, LabeledStatement, Program, VariableDeclaration } from 'estree'; import type { Identifier, LabeledStatement, Node, Program } from 'estree';
import type { Scope, ScopeRoot } from './scope.js'; import type { Scope, ScopeRoot } from './scope.js';
export interface Js { export interface Js {
@ -62,7 +62,6 @@ export interface ComponentAnalysis extends Analysis {
/** If `true`, should append styles through JavaScript */ /** If `true`, should append styles through JavaScript */
inject_styles: boolean; inject_styles: boolean;
reactive_statements: Map<LabeledStatement, ReactiveStatement>; reactive_statements: Map<LabeledStatement, ReactiveStatement>;
top_level_snippets: VariableDeclaration[];
/** Identifiers that make up the `bind:group` expression -> internal group binding name */ /** Identifiers that make up the `bind:group` expression -> internal group binding name */
binding_groups: Map<[key: string, bindings: Array<Binding | null>], Identifier>; binding_groups: Map<[key: string, bindings: Array<Binding | null>], Identifier>;
slot_names: Map<string, AST.SlotElement>; slot_names: Map<string, AST.SlotElement>;
@ -72,6 +71,7 @@ export interface ComponentAnalysis extends Analysis {
keyframes: string[]; keyframes: string[];
}; };
source: string; source: string;
undefined_exports: Map<string, Node>;
/** /**
* Every render tag/component, and whether it could be definitively resolved or not * Every render tag/component, and whether it could be definitively resolved or not
*/ */

@ -279,7 +279,8 @@ export interface Binding {
| 'snippet' | 'snippet'
| 'store_sub' | 'store_sub'
| 'legacy_reactive' | 'legacy_reactive'
| 'template'; | 'template'
| 'snippet';
declaration_kind: DeclarationKind; declaration_kind: DeclarationKind;
/** /**
* What the value was initialized with. * What the value was initialized with.

@ -461,6 +461,7 @@ export namespace AST {
body: Fragment; body: Fragment;
/** @internal */ /** @internal */
metadata: { metadata: {
can_hoist: boolean;
/** The set of components/render tags that could render this snippet, /** The set of components/render tags that could render this snippet,
* used for CSS pruning */ * used for CSS pruning */
sites: Set<Component | SvelteComponent | SvelteSelf | RenderTag>; sites: Set<Component | SvelteComponent | SvelteSelf | RenderTag>;

@ -0,0 +1,9 @@
import { test } from '../../test';
export default test({
error: {
code: 'export_undefined',
message: '`blah` is not defined',
position: [26, 30]
}
});

@ -0,0 +1,13 @@
<script module>
const message = 'hello';
export { one };
</script>
{#snippet one()}
{@render two()}
{/snippet}
{#snippet two()}
{message}
{/snippet}

@ -0,0 +1,8 @@
import { test } from '../../test';
export default test({
compileOptions: {
dev: true // Render in dev mode to check that the validation error is not thrown
},
html: `hello`
});

@ -0,0 +1,5 @@
<script>
import { one } from './Child.svelte';
</script>
{@render one()}

@ -0,0 +1,9 @@
<script module>
export {
foo
}
</script>
{#snippet foo(a, b)}
Hello world {a + b}
{/snippet}

@ -0,0 +1,8 @@
import { test } from '../../test';
export default test({
compileOptions: {
dev: true // Render in dev mode to check that the validation error is not thrown
},
html: `Hello world 3`
});

@ -0,0 +1,5 @@
<script>
import { foo } from './Child.svelte';
</script>
{@render foo(1, 2)}

@ -2,17 +2,17 @@ import "svelte/internal/disclose-version";
import * as $ from "svelte/internal/client"; import * as $ from "svelte/internal/client";
import TextInput from './Child.svelte'; import TextInput from './Child.svelte';
var root = $.template(`<!> `, 1); const snippet = ($$anchor) => {
$.next();
export default function Bind_component_snippet($$anchor) { var text = $.text("Something");
const snippet = ($$anchor) => {
$.next();
var text = $.text("Something"); $.append($$anchor, text);
};
$.append($$anchor, text); var root = $.template(`<!> `, 1);
};
export default function Bind_component_snippet($$anchor) {
let value = $.state(''); let value = $.state('');
const _snippet = snippet; const _snippet = snippet;
var fragment = root(); var fragment = root();

@ -1,14 +1,13 @@
import * as $ from "svelte/internal/server"; import * as $ from "svelte/internal/server";
import TextInput from './Child.svelte'; import TextInput from './Child.svelte';
function snippet($$payload) {
$$payload.out += `<!---->Something`;
}
export default function Bind_component_snippet($$payload) { export default function Bind_component_snippet($$payload) {
let value = ''; let value = '';
const _snippet = snippet; const _snippet = snippet;
function snippet($$payload) {
$$payload.out += `<!---->Something`;
}
let $$settled = true; let $$settled = true;
let $$inner_payload; let $$inner_payload;

Loading…
Cancel
Save