Skip to content

Commit

Permalink
Preserve "use client" module boundaries (#516)
Browse files Browse the repository at this point in the history
  • Loading branch information
emmatown committed Mar 24, 2023
1 parent 57576f9 commit d363c88
Show file tree
Hide file tree
Showing 4 changed files with 281 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/gorgeous-jars-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@preconstruct/cli": minor
---

Modules with `"use client"` directives are now built as their own chunk with the `"use client"` directive preserved.
199 changes: 199 additions & 0 deletions packages/cli/src/build/__tests__/other.ts
Original file line number Diff line number Diff line change
Expand Up @@ -812,3 +812,202 @@ test(".d.ts", async () => {
`);
});

// TODO: the hashes are unpredictable on windows for some reason so these tests are skipped for now
if (process.platform !== "win32") {
test("use client", async () => {
const dir = await testdir({
"package.json": JSON.stringify({
name: "pkg",
main: "dist/pkg.cjs.js",
module: "dist/pkg.esm.js",
}),
"src/index.js": js`
export { A } from "./client";
export { C } from "./c";
export { B } from "./b";
`,
"src/client.js": js`
"use client";
export const A = "something";
`,
"src/b.js": js`
export const B = "b";
`,
"src/c.js": js`
import { D } from "./d";
export function C() {
return D;
}
`,
"src/d.js": js`
"use client";
export const D = "d";
`,
});
let originalProcessCwd = process.cwd;
try {
process.cwd = () => dir;
await build(dir);
expect(await getFiles(dir, ["dist/**"])).toMatchInlineSnapshot(`
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/client-20a7a0ce.cjs.dev.js, dist/client-d0bc5238.cjs.prod.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
"use client";
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
const A = "something";
exports.A = A;
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/client-9868ff2a.esm.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
"use client";
const A = "something";
export { A };
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/client-d0bc5238.cjs.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
'use strict';
if (process.env.NODE_ENV === "production") {
module.exports = require("./client-d0bc5238.cjs.prod.js");
} else {
module.exports = require("./client-d0bc5238.cjs.dev.js");
}
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/d-2af6f49d.cjs.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
'use strict';
if (process.env.NODE_ENV === "production") {
module.exports = require("./d-2af6f49d.cjs.prod.js");
} else {
module.exports = require("./d-2af6f49d.cjs.dev.js");
}
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/d-2af6f49d.cjs.prod.js, dist/d-89a2b600.cjs.dev.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
"use client";
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
const D = "d";
exports.D = D;
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/d-77f3a222.esm.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
"use client";
const D = "d";
export { D };
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/pkg.cjs.dev.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var client = require('./client-20a7a0ce.cjs.dev.js');
var d = require('./d-89a2b600.cjs.dev.js');
function C() {
return d.D;
}
const B = "b";
exports.A = client.A;
exports.B = B;
exports.C = C;
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/pkg.cjs.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
'use strict';
if (process.env.NODE_ENV === "production") {
module.exports = require("./pkg.cjs.prod.js");
} else {
module.exports = require("./pkg.cjs.dev.js");
}
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/pkg.cjs.prod.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var client = require('./client-d0bc5238.cjs.prod.js');
var d = require('./d-2af6f49d.cjs.prod.js');
function C() {
return d.D;
}
const B = "b";
exports.A = client.A;
exports.B = B;
exports.C = C;
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/pkg.esm.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
export { A } from './client-9868ff2a.esm.js';
import { D } from './d-77f3a222.esm.js';
function C() {
return D;
}
const B = "b";
export { B, C };
`);
} finally {
process.cwd = originalProcessCwd;
}
});

test("use client as entrypoint", async () => {
const dir = await testdir({
"package.json": JSON.stringify({
name: "pkg",
main: "dist/pkg.cjs.js",
module: "dist/pkg.esm.js",
}),
"src/index.js": js`
"use client";
export const a = true;
`,
});
let originalProcessCwd = process.cwd;
try {
process.cwd = () => dir;
await build(dir);
expect(await getFiles(dir, ["dist/**"])).toMatchInlineSnapshot(`
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/pkg.cjs.dev.js, dist/pkg.cjs.prod.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
"use client";
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
const a = true;
exports.a = a;
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/pkg.cjs.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
'use strict';
if (process.env.NODE_ENV === "production") {
module.exports = require("./pkg.cjs.prod.js");
} else {
module.exports = require("./pkg.cjs.dev.js");
}
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/pkg.esm.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
"use client";
const a = true;
export { a };
`);
} finally {
process.cwd = originalProcessCwd;
}
});
}
2 changes: 2 additions & 0 deletions packages/cli/src/build/rollup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { getBaseDistName } from "../utils";
import { EXTENSIONS } from "../constants";
import { inlineProcessEnvNodeEnv } from "../rollup-plugins/inline-process-env-node-env";
import normalizePath from "normalize-path";
import { serverComponentsPlugin } from "../rollup-plugins/server-components";

type ExternalPredicate = (source: string) => boolean;

Expand Down Expand Up @@ -175,6 +176,7 @@ export let getRollupConfig = (
json({
namedExports: false,
}),
serverComponentsPlugin({ sourceMap: type === "umd" }),
type === "umd" &&
alias({
entries: aliases,
Expand Down
75 changes: 75 additions & 0 deletions packages/cli/src/rollup-plugins/server-components.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { AcornNode, Plugin, SourceDescription } from "rollup";
import { Program } from "estree";
import MagicString, { SourceMap } from "magic-string";

export function serverComponentsPlugin({
sourceMap,
}: {
sourceMap: boolean;
}): Plugin {
return {
name: "server-components",

transform(code, id) {
if (/['"]use client['"]/.test(code)) {
const ast: Program = (() => {
const babelMeta = this.getModuleInfo(id)!.meta.babel;
if (babelMeta?.codeAtBabelTime === code) {
return this.getModuleInfo(id)!.meta.babel.ast;
}
return this.parse(code);
})();

for (const [idx, node] of ast.body.entries()) {
if (
node.type !== "ExpressionStatement" ||
node.expression.type !== "Literal" ||
typeof node.expression.value !== "string"
) {
return;
}
if (node.expression.value === "use client") {
let magicString = new MagicString(code);
this.emitFile({ type: "chunk", id });
ast.body.splice(idx, 1);
const start: number = (node as any).start;
const end: number = (node as any).end;
const len = end - start;
magicString.overwrite(start, end, " ".repeat(len));
let output: SourceDescription = {
code: magicString.toString(),
ast: (ast as unknown) as AcornNode,
};

if (sourceMap) {
output.map = magicString.generateMap({ hires: true });
}
if (!output.meta) {
output.meta = {};
}
output.meta.isUseClientEntry = true;
return output;
}
}
}
return null;
},
renderChunk(code, chunk) {
if (chunk.facadeModuleId !== null) {
const moduleInfo = this.getModuleInfo(chunk.facadeModuleId);
if (moduleInfo?.meta.isUseClientEntry) {
const magicString = new MagicString(code);
magicString.prepend('"use client";\n');
const chunkInfo: { code: string; map?: SourceMap } = {
code: magicString.toString(),
};
if (sourceMap) {
chunkInfo.map = magicString.generateMap({ hires: true });
}
return chunkInfo;
}
}
return null;
},
};
}

0 comments on commit d363c88

Please sign in to comment.