Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow snippets to be exported from module scripts #14315

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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': patch
---

feat: allow snippets to be exported from module scripts
6 changes: 6 additions & 0 deletions documentation/docs/98-reference/.generated/compile-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,12 @@ 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

```
Cannot export snippet from a `<script module>` if it references logic or expressions inside the component
```

### snippet_invalid_rest_parameter

```
Expand Down
4 changes: 4 additions & 0 deletions packages/svelte/messages/compile-errors/script.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@

> %name% cannot be used in runes mode
## snippet_invalid_export

> Cannot export snippet from a `<script module>` if it references logic or expressions inside the component
## snippet_parameter_assignment

> Cannot reassign or bind to snippet parameter
Expand Down
9 changes: 9 additions & 0 deletions packages/svelte/src/compiler/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,15 @@ export function runes_mode_invalid_import(node, name) {
e(node, "runes_mode_invalid_import", `${name} cannot be used in runes mode`);
}

/**
* Cannot export snippet from a `<script module>` if it references logic or expressions inside the component
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function snippet_invalid_export(node) {
e(node, "snippet_invalid_export", "Cannot export snippet from a `<script module>` if it references logic or expressions inside the component");
}

/**
* 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
5 changes: 4 additions & 1 deletion packages/svelte/src/compiler/phases/1-parse/state/tag.js
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,10 @@ function open(parser) {
name
},
parameters: function_expression.params,
body: create_fragment()
body: create_fragment(),
metadata: {
can_hoist: false
}
});
parser.stack.push(block);
parser.fragments.push(block.body);
Expand Down
4 changes: 3 additions & 1 deletion packages/svelte/src/compiler/phases/2-analyze/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ export function analyze_component(root, source, options) {
binding_groups: new Map(),
slot_names: new Map(),
top_level_snippets: [],
module_level_snippets: [],
css: {
ast: root.css,
hash: root.css
Expand All @@ -437,7 +438,8 @@ export function analyze_component(root, source, options) {
: '',
keyframes: []
},
source
source,
undefined_exports: new Map()
};

if (!runes) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
/** @import { ExportSpecifier, Node } from 'estree' */
/** @import { Binding } from '#compiler' */
/** @import { Context } from '../types' */
/** @import { Scope } from '../../scope' */
import * as e from '../../../errors.js';
Expand Down Expand Up @@ -27,7 +26,8 @@ export function ExportSpecifier(node, context) {
if (binding) binding.reassigned = binding.updated = true;
}
} else {
validate_export(node, context.state.scope, local_name);
const undefined_exports = context.state.analysis.undefined_exports;
validate_export(node, context.state.scope, local_name, undefined_exports);
}
}

Expand All @@ -36,10 +36,14 @@ export function ExportSpecifier(node, context) {
* @param {Node} node
* @param {Scope} scope
* @param {string} name
* @param {Map<string, any>} undefined_exports
*/
function validate_export(node, scope, name) {
function validate_export(node, scope, name, undefined_exports) {
const binding = scope.get(name);
if (!binding) return;
if (!binding) {
undefined_exports.set(name, node);
return;
}

if (binding.kind === 'derived') {
e.derived_invalid_export(node);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/** @import { Context } from '../types' */
import { validate_block_not_empty, validate_opening_tag } from './shared/utils.js';
import * as e from '../../../errors.js';
import { can_hoist_snippet } from '../../3-transform/utils.js';

/**
* @param {AST.SnippetBlock} node
Expand All @@ -22,6 +23,20 @@ export function SnippetBlock(node, context) {

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

const local_scope = context.state.scope;
const can_hoist =
context.path.length === 1 &&
context.path[0].type === 'Fragment' &&
can_hoist_snippet(node, local_scope, context.state.scopes);
const undefined_exports = context.state.analysis.undefined_exports;
const name = node.expression.name;

if (!can_hoist && undefined_exports.has(name)) {
e.snippet_invalid_export(/** @type {any} */ (undefined_exports.get(name)));
}

node.metadata.can_hoist = can_hoist;

const { path } = context;
const parent = path.at(-2);
if (!parent) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ export function client_component(analysis, options) {
}
}

body = [...imports, ...body];
body = [...imports, ...analysis.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 @@ -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.analysis.module_level_snippets.push(declaration);
} else {
context.state.analysis.top_level_snippets.push(declaration);
}
} else {
context.state.init.push(declaration);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @import { BlockStatement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */
/** @import { ComponentContext, } from '../types.js' */
import * as b from '../../../../utils/builders.js';

/**
Expand All @@ -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);
}
}
51 changes: 51 additions & 0 deletions packages/svelte/src/compiler/phases/3-transform/utils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @import { Context } from 'zimmerframe' */
/** @import { TransformState } from './types.js' */
/** @import { Scope } from '../scope.js' */
/** @import { AST, Binding, Namespace, SvelteNode, ValidatedCompileOptions } from '#compiler' */
/** @import { Node, Expression, CallExpression } from 'estree' */
import {
Expand Down Expand Up @@ -452,3 +453,53 @@ export function transform_inspect_rune(node, context) {
return b.call('$.inspect', as_fn ? b.thunk(b.array(arg)) : b.array(arg));
}
}

/**
* @param {AST.SnippetBlock} node
* @param {Map<SvelteNode, Scope>} scopes
* @param {Scope} scope
*/
export function can_hoist_snippet(node, scope, scopes, visited = new Set()) {
let can_hoist = true;

ref_loop: for (const [reference] of scope.references) {
const local_binding = scope.get(reference);

if (local_binding) {
if (local_binding.node === node.expression || local_binding.scope.function_depth === 0) {
continue;
}
/** @type {Scope | null} */
let current_scope = local_binding.scope;

while (current_scope !== null) {
if (current_scope === scope) {
continue ref_loop;
}
current_scope = current_scope.parent;
}

// Recursively check if another snippet can be hoisted
if (local_binding.kind === 'normal') {
for (const ref of local_binding.references) {
const parent = ref.path.at(-1);
if (ref.node === local_binding.node && parent?.type === 'SnippetBlock') {
const ref_scope = scopes.get(parent);
if (visited.has(ref)) {
break;
}
visited.add(ref);
if (ref_scope && can_hoist_snippet(parent, ref_scope, scopes, visited)) {
continue ref_loop;
}
break;
}
}
}
can_hoist = false;
break;
}
}

return can_hoist;
}
2 changes: 2 additions & 0 deletions packages/svelte/src/compiler/phases/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface ComponentAnalysis extends Analysis {
inject_styles: boolean;
reactive_statements: Map<LabeledStatement, ReactiveStatement>;
top_level_snippets: VariableDeclaration[];
module_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>;
Expand All @@ -72,6 +73,7 @@ export interface ComponentAnalysis extends Analysis {
keyframes: string[];
};
source: string;
undefined_exports: Map<string, Node>;
}

declare module 'estree' {
Expand Down
3 changes: 2 additions & 1 deletion packages/svelte/src/compiler/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,8 @@ export interface Binding {
| 'snippet'
| 'store_sub'
| 'legacy_reactive'
| 'template';
| 'template'
| 'snippet';
declaration_kind: DeclarationKind;
/**
* What the value was initialized with.
Expand Down
3 changes: 3 additions & 0 deletions packages/svelte/src/compiler/types/template.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,9 @@ export namespace AST {
expression: Identifier;
parameters: Pattern[];
body: Fragment;
metadata: {
can_hoist: boolean;
};
}

export interface Attribute extends BaseNode {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,4 @@
<svelte:fragment let:should_stay slot="cool stuff">
cool
</svelte:fragment>
</Comp>
</Comp>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script module>
const message = 'hello';
export { one };
</script>

{#snippet one()}
{@render two()}
{/snippet}

{#snippet two()}
{message}
{/snippet}
Original file line number Diff line number Diff line change
@@ -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`
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script>
import { one } from './Child.svelte';
</script>

{@render one()}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<script module>
export {
foo
}
</script>

{#snippet foo(a, b)}
Hello world {a + b}
{/snippet}
Original file line number Diff line number Diff line change
@@ -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`
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script>
import { foo } from './Child.svelte';
</script>

{@render foo(1, 2)}
Loading
Loading