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>
```
## 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
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
```
### export_undefined
```
`%name%` is not defined
```
### 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
```
### 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
```

@ -38,6 +38,10 @@
> `$effect()` can only be used as an expression statement
## export_undefined
> `%name%` is not defined
## global_reference_invalid
> `%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
## 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
> 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");
}
/**
* `%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%`
* @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`);
}
/**
* 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
* @param {null | number | NodeLike} node

@ -10,17 +10,43 @@ const ParserWithTS = acorn.Parser.extend(tsPlugin({ allowSatisfies: true }));
/**
* @param {string} source
* @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 { onComment, add_comments } = get_comment_handlers(source);
const ast = parser.parse(source, {
onComment,
sourceType: 'module',
ecmaVersion: 13,
locations: true
});
// @ts-ignore
const parse_statement = parser.prototype.parseStatement;
// If we're dealing with a <script> then it might contain an export
// for something that doesn't exist directly inside but is inside the
// 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);
add_comments(ast);

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

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

@ -429,7 +429,6 @@ export function analyze_component(root, source, options) {
reactive_statements: new Map(),
binding_groups: new Map(),
slot_names: new Map(),
top_level_snippets: [],
css: {
ast: root.css,
hash: root.css
@ -443,6 +442,7 @@ export function analyze_component(root, source, options) {
keyframes: []
},
source,
undefined_exports: new Map(),
snippet_renderers: new Map(),
snippets: new Set()
};
@ -697,6 +697,17 @@ export function analyze_component(root, source, options) {
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) {
e.mixed_event_handler_syntaxes(
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 { validate_block_not_empty, validate_opening_tag } from './shared/utils.js';
import * as e from '../../../errors.js';
@ -24,6 +25,25 @@ export function SnippetBlock(node, context) {
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 parent = path.at(-2);
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(),
transform: {},
in_constructor: false,
instance_level_snippets: [],
module_level_snippets: [],
// these are set inside the `Fragment` visitor, and cannot be used until then
before_init: /** @type {any} */ (null),
@ -370,7 +372,7 @@ export function client_component(analysis, options) {
...store_setup,
...legacy_reactive_declarations,
...group_binding_declarations,
...analysis.top_level_snippets,
...state.instance_level_snippets,
.../** @type {ESTree.Statement[]} */ (instance.body),
analysis.runes || !analysis.needs_context
? 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(
b.id(analysis.name),

@ -6,7 +6,8 @@ import type {
PrivateIdentifier,
Expression,
AssignmentExpression,
UpdateExpression
UpdateExpression,
VariableDeclaration
} from 'estree';
import type { Namespace, SvelteNode, ValidatedCompileOptions } from '#compiler';
import type { TransformState } from '../types.js';
@ -85,6 +86,11 @@ export interface ComponentClientTransformState extends ClientTransformState {
/** The $: calls, which will be ordered in the end */
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 {

@ -83,7 +83,11 @@ export function SnippetBlock(node, context) {
// Top-level snippets are hoisted so they can be referenced in the `<script>`
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 {
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
fn.___snippet = true;
// TODO hoist where possible
context.state.init.push(fn);
if (node.metadata.can_hoist) {
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 { Identifier, LabeledStatement, Program, VariableDeclaration } from 'estree';
import type { Identifier, LabeledStatement, Node, Program } from 'estree';
import type { Scope, ScopeRoot } from './scope.js';
export interface Js {
@ -62,7 +62,6 @@ export interface ComponentAnalysis extends Analysis {
/** If `true`, should append styles through JavaScript */
inject_styles: boolean;
reactive_statements: Map<LabeledStatement, ReactiveStatement>;
top_level_snippets: VariableDeclaration[];
/** Identifiers that make up the `bind:group` expression -> internal group binding name */
binding_groups: Map<[key: string, bindings: Array<Binding | null>], Identifier>;
slot_names: Map<string, AST.SlotElement>;
@ -72,6 +71,7 @@ export interface ComponentAnalysis extends Analysis {
keyframes: string[];
};
source: string;
undefined_exports: Map<string, Node>;
/**
* Every render tag/component, and whether it could be definitively resolved or not
*/

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

@ -461,6 +461,7 @@ export namespace AST {
body: Fragment;
/** @internal */
metadata: {
can_hoist: boolean;
/** The set of components/render tags that could render this snippet,
* used for CSS pruning */
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 TextInput from './Child.svelte';
var root = $.template(`<!> `, 1);
const snippet = ($$anchor) => {
$.next();
export default function Bind_component_snippet($$anchor) {
const snippet = ($$anchor) => {
$.next();
var text = $.text("Something");
var text = $.text("Something");
$.append($$anchor, text);
};
$.append($$anchor, text);
};
var root = $.template(`<!> `, 1);
export default function Bind_component_snippet($$anchor) {
let value = $.state('');
const _snippet = snippet;
var fragment = root();

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

Loading…
Cancel
Save