Skip to content

Commit 61a2943

Browse files
committed
feat: allow snippets to be exported from module scripts
1 parent efc65d4 commit 61a2943

File tree

15 files changed

+126
-27
lines changed

15 files changed

+126
-27
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': patch
3+
---
4+
5+
feat: allow snippets to be exported from module scripts

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/2-analyze/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ export function analyze_component(root, source, options) {
425425
binding_groups: new Map(),
426426
slot_names: new Map(),
427427
top_level_snippets: [],
428+
module_level_snippets: [],
428429
css: {
429430
ast: root.css,
430431
hash: root.css

packages/svelte/src/compiler/phases/3-transform/client/transform-client.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,7 @@ export function client_component(analysis, options) {
483483
}
484484
}
485485

486-
body = [...imports, ...body];
486+
body = [...imports, ...analysis.module_level_snippets, ...body];
487487

488488
const component = b.function_declaration(
489489
b.id(analysis.name),

packages/svelte/src/compiler/phases/3-transform/client/visitors/SnippetBlock.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { dev } from '../../../../state.js';
55
import { extract_paths } from '../../../../utils/ast.js';
66
import * as b from '../../../../utils/builders.js';
7+
import { can_hoist_snippet } from '../../utils.js';
78
import { get_value } from './shared/declarations.js';
89

910
/**
@@ -80,10 +81,16 @@ export function SnippetBlock(node, context) {
8081
}
8182

8283
const declaration = b.const(node.expression, snippet);
84+
const local_scope = context.state.scope;
85+
const can_hoist = can_hoist_snippet(node, local_scope);
8386

8487
// Top-level snippets are hoisted so they can be referenced in the `<script>`
8588
if (context.path.length === 1 && context.path[0].type === 'Fragment') {
86-
context.state.analysis.top_level_snippets.push(declaration);
89+
if (can_hoist) {
90+
context.state.analysis.module_level_snippets.push(declaration);
91+
} else {
92+
context.state.analysis.top_level_snippets.push(declaration);
93+
}
8794
} else {
8895
context.state.init.push(declaration);
8996
}

packages/svelte/src/compiler/phases/3-transform/server/visitors/SnippetBlock.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/** @import { BlockStatement } from 'estree' */
22
/** @import { AST } from '#compiler' */
3-
/** @import { ComponentContext } from '../types.js' */
3+
/** @import { ComponentContext, } from '../types.js' */
44
import * as b from '../../../../utils/builders.js';
5+
import { can_hoist_snippet } from '../../utils.js';
56

67
/**
78
* @param {AST.SnippetBlock} node
@@ -17,6 +18,11 @@ export function SnippetBlock(node, context) {
1718
// @ts-expect-error - TODO remove this hack once $$render_inner for legacy bindings is gone
1819
fn.___snippet = true;
1920

20-
// TODO hoist where possible
21-
context.state.init.push(fn);
21+
const can_hoist = can_hoist_snippet(node, context.state.scope);
22+
23+
if (context.path.length === 1 && context.path[0].type === 'Fragment' && can_hoist) {
24+
context.state.hoisted.push(fn);
25+
} else {
26+
context.state.init.push(fn);
27+
}
2228
}

packages/svelte/src/compiler/phases/3-transform/utils.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** @import { Context } from 'zimmerframe' */
22
/** @import { TransformState } from './types.js' */
3+
/** @import { Scope } from '../scope.js' */
34
/** @import { AST, Binding, Namespace, SvelteNode, ValidatedCompileOptions } from '#compiler' */
45
/** @import { Node, Expression, CallExpression } from 'estree' */
56
import {
@@ -452,3 +453,34 @@ export function transform_inspect_rune(node, context) {
452453
return b.call('$.inspect', as_fn ? b.thunk(b.array(arg)) : b.array(arg));
453454
}
454455
}
456+
457+
/**
458+
* @param {AST.SnippetBlock} node
459+
* @param {Scope} scope
460+
*/
461+
export function can_hoist_snippet(node, scope) {
462+
let can_hoist = true;
463+
464+
ref_loop: for (const [reference] of scope.references) {
465+
const local_binding = scope.get(reference);
466+
467+
if (local_binding) {
468+
if (local_binding.node === node.expression) {
469+
continue;
470+
}
471+
/** @type {Scope | null} */
472+
let current_scope = local_binding.scope;
473+
474+
while (current_scope !== null) {
475+
if (current_scope === scope) {
476+
continue ref_loop;
477+
}
478+
current_scope = current_scope.parent;
479+
}
480+
can_hoist = false;
481+
break;
482+
}
483+
}
484+
485+
return can_hoist;
486+
}

packages/svelte/src/compiler/phases/types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export interface ComponentAnalysis extends Analysis {
6363
inject_styles: boolean;
6464
reactive_statements: Map<LabeledStatement, ReactiveStatement>;
6565
top_level_snippets: VariableDeclaration[];
66+
module_level_snippets: VariableDeclaration[];
6667
/** Identifiers that make up the `bind:group` expression -> internal group binding name */
6768
binding_groups: Map<[key: string, bindings: Array<Binding | null>], Identifier>;
6869
slot_names: Map<string, AST.SlotElement>;

packages/svelte/tests/migrate/samples/slot-non-identifier/output.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,4 @@
8282
<svelte:fragment let:should_stay slot="cool stuff">
8383
cool
8484
</svelte:fragment>
85-
</Comp>
85+
</Comp>

0 commit comments

Comments
 (0)