Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/fix-2100-exports-disappear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/start": patch
---

fix: keep non-picked route exports that picked exports reference instead of deleting their bindings, so API handlers can call helpers exported from the same file (#2100); also fixes picked exports declared via `export { ... }` specifiers being dropped (#1659)
140 changes: 140 additions & 0 deletions packages/start/src/config/fs-routes/tree-shake.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";

import { treeShake } from "./tree-shake.ts";

async function shake(code: string, pick: string[]) {
const plugin = treeShake() as any;
const id = `/routes/route.ts?${pick.map(p => `pick=${p}`).join("&")}`;
const result = await plugin.transform(code, id);
return result?.code as string | undefined;
}

describe("treeShake", () => {
it("keeps only the picked export", async () => {
const code = `
export const GET = () => "get";
export const POST = () => "post";
`;

const output = await shake(code, ["GET"]);

expect(output).toContain("export const GET");
expect(output).not.toContain("POST");
});

// https://github.com/solidjs/solid-start/issues/2100
it("keeps a non-picked export that a picked export references", async () => {
const code = `
export const hello = () => "hello";
export const GET = async () => {
return hello();
};
`;

const output = await shake(code, ["GET"]);

expect(output).toContain(`const hello = () => "hello"`);
expect(output).not.toContain("export const hello");
expect(output).toContain("export const GET");
});

it("keeps a non-picked exported function that a picked export references", async () => {
const code = `
export function helper() {
return "helper";
}
export const GET = () => helper();
`;

const output = await shake(code, ["GET"]);

expect(output).toContain("function helper()");
expect(output).not.toContain("export function helper");
expect(output).toContain("export const GET");
});

it("removes a non-picked export that nothing references", async () => {
const code = `
export const secret = globalThis.createSecret();
export const GET = () => "ok";
`;

const output = await shake(code, ["GET"]);

expect(output).not.toContain("secret");
expect(output).toContain("export const GET");
});

// https://github.com/solidjs/solid-start/issues/1659
it("keeps picked exports declared via specifiers, including aliases", async () => {
const code = `
const handler = () => "ok";
export { handler as GET, handler as POST };
`;

const output = await shake(code, ["GET"]);

expect(output).toContain("handler as GET");
expect(output).not.toContain("POST");
});

it("splits mixed variable declarations while preserving evaluation order", async () => {
const code = `
export const first = 1, GET = () => first;
`;

const output = await shake(code, ["GET"]);

expect(output).toMatch(/const first = 1;\s*export const GET/);
expect(output).not.toContain("export const first");
});

it("keeps a named default export that a picked export references", async () => {
const code = `
export default function Page() {
return "page";
}
export const GET = () => Page();
`;

const output = await shake(code, ["GET"]);

expect(output).toContain("function Page()");
expect(output).not.toContain("export default");
expect(output).toContain("export const GET");
});

it("removes an unreferenced default export, including anonymous ones", async () => {
const named = await shake(
`
export default function Page() { return "page"; }
export const GET = () => "ok";
`,
["GET"],
);
expect(named).not.toContain("Page");

const anonymous = await shake(
`
export default function () { return "page"; }
export const GET = () => "ok";
`,
["GET"],
);
expect(anonymous).not.toContain("export default");
expect(anonymous).toContain("export const GET");
});

it("removes imports only used by removed exports", async () => {
const code = `
import { db } from "./db.ts";
export const POST = () => db.write();
export const GET = () => "ok";
`;

const output = await shake(code, ["GET"]);

expect(output).not.toContain("db");
expect(output).toContain("export const GET");
});
});
64 changes: 47 additions & 17 deletions packages/start/src/config/fs-routes/tree-shake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,17 @@ function treeShakeTransform({ types: t }: typeof Babel): PluginObj<State> {
ExportDefaultDeclaration(exportNamedPath) {
// if opts.keep is true, we don't remove the routeData export
if (state.opts.pick && !state.opts.pick.includes("default")) {
exportNamedPath.remove();
const decl = exportNamedPath.node.declaration;
Comment thread
brenelz marked this conversation as resolved.
// A named function/class declaration creates a module-scope
// binding that picked exports may reference, so only strip
// the `export default`; the sweep below removes the
// declaration again if nothing references it.
if ((t.isFunctionDeclaration(decl) || t.isClassDeclaration(decl)) && decl.id) {
const [newPath] = exportNamedPath.replaceWith(decl);
state.refs.add((newPath as NodePath<any>).get("id"));
} else {
exportNamedPath.remove();
}
}
},
ExportNamedDeclaration(exportNamedPath) {
Expand All @@ -121,11 +131,9 @@ function treeShakeTransform({ types: t }: typeof Babel): PluginObj<State> {
const specifiers = exportNamedPath.get("specifiers");
if (specifiers.length) {
specifiers.forEach(s => {
if (
t.isIdentifier(s.node.exported)
? s.node.exported.name
: state.opts.pick.includes(s.node.exported.value)
) {
const exported = s.node.exported;
const exportedName = t.isIdentifier(exported) ? exported.name : exported.value;
if (!state.opts.pick.includes(exportedName)) {
s.remove();
}
});
Expand All @@ -139,24 +147,46 @@ function treeShakeTransform({ types: t }: typeof Babel): PluginObj<State> {
return;
}
switch (decl.node.type) {
case "FunctionDeclaration": {
case "FunctionDeclaration":
case "ClassDeclaration": {
const name = decl.node.id?.name;
if (name && state.opts.pick && !state.opts.pick.includes(name)) {
exportNamedPath.remove();
// Keep the declaration in module scope since picked
// exports may reference it; the sweep below removes it
// again if unreferenced.
const [newPath] = exportNamedPath.replaceWith(decl.node);
state.refs.add((newPath as NodePath<any>).get("id"));
}
break;
}
case "VariableDeclaration": {
const inner = decl.get("declarations") as Array<NodePath<any>>;
inner.forEach(d => {
if (d.node.id.type !== "Identifier") {
return;
}
const name = d.node.id.name;
if (state.opts.pick && !state.opts.pick.includes(name)) {
d.remove();
}
const declNode = decl.node;
// Destructuring declarators are conservatively treated as
// picked (matching the previous behavior of leaving them
// untouched).
const isPicked = (d: (typeof declNode.declarations)[number]) =>
d.id.type !== "Identifier" || state.opts.pick.includes(d.id.name);
if (declNode.declarations.every(isPicked)) {
break;
}
// Split into one declaration per declarator (preserving
// evaluation order) and export only the picked ones.
// Unpicked bindings are kept since picked exports may
// reference them; the sweep below removes them again if
// unreferenced.
const statements = declNode.declarations.map(d => {
const single = t.variableDeclaration(declNode.kind, [d]);
return isPicked(d) ? t.exportNamedDeclaration(single, []) : single;
});
const newPaths = exportNamedPath.replaceWithMultiple(statements);
for (const p of newPaths) {
if (!p.isVariableDeclaration()) continue;
for (const d of p.get("declarations")) {
if (d.node.id.type === "Identifier") {
state.refs.add(d.get("id"));
}
}
}
break;
}
default: {
Expand Down
Loading