Skip to content

Commit

Permalink
feat: allow snippets to be exported from module scripts (#14315)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people authored Dec 3, 2024
1 parent 2e57612 commit 4d2f2fb
Show file tree
Hide file tree
Showing 27 changed files with 292 additions and 32 deletions.
5 changes: 5 additions & 0 deletions .changeset/famous-parents-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: allow snippets to be exported from module scripts
14 changes: 14 additions & 0 deletions documentation/docs/03-template-syntax/06-snippet.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions documentation/docs/98-reference/.generated/compile-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,12 @@ Expected token %token%
Expected whitespace
```

### export_undefined

```
`%name%` is not defined
```

### global_reference_invalid

```
Expand Down Expand Up @@ -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

```
Expand Down
26 changes: 26 additions & 0 deletions packages/svelte/messages/compile-errors/script.md
Original file line number Diff line number Diff line change
Expand Up @@ -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%`
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions packages/svelte/src/compiler/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
42 changes: 34 additions & 8 deletions packages/svelte/src/compiler/phases/1-parse/acorn.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/svelte/src/compiler/phases/1-parse/read/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/src/compiler/phases/1-parse/state/tag.js
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ function open(parser) {
parameters: function_expression.params,
body: create_fragment(),
metadata: {
can_hoist: false,
sites: new Set()
}
});
Expand Down
13 changes: 12 additions & 1 deletion packages/svelte/src/compiler/phases/2-analyze/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -443,6 +442,7 @@ export function analyze_component(root, source, options) {
keyframes: []
},
source,
undefined_exports: new Map(),
snippet_renderers: new Map(),
snippets: new Set()
};
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading

0 comments on commit 4d2f2fb

Please sign in to comment.