Skip to content

Commit e9b0856

Browse files
authored
fix react import (#1229)
* fix node imports * prettier * add missing mockJsDelivr * log resolveNodeImport error * fix react import * more fixes * more fixes * fix input; tsc * more consistent promising? * more test isolation
1 parent 46e7203 commit e9b0856

File tree

13 files changed

+255
-90
lines changed

13 files changed

+255
-90
lines changed

docs/lib/react.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# React
2+
3+
```js echo
4+
import {createRoot} from "react-dom/client";
5+
6+
const root = createRoot(display(document.createElement("DIV")));
7+
```
8+
9+
The code above creates a root; a place for React content to live. We render some content into the root below.
10+
11+
```js echo
12+
root.render(jsxs(createContent, {}));
13+
```
14+
15+
The content is defined as a component, but hand-authored using the JSX runtime. You wouldn’t normally write this code by hand, but Framework doesn’t support JSX yet. We’re working on it.
16+
17+
```js echo
18+
import {useState} from "react";
19+
import {Fragment, jsx, jsxs} from "react/jsx-runtime";
20+
21+
function createContent() {
22+
const [counter, setCounter] = useState(0);
23+
return jsxs(Fragment, {
24+
children: [
25+
jsx("p", {
26+
children: ["Hello, world! ", counter]
27+
}),
28+
"\n",
29+
jsx("p", {
30+
children: "This content is rendered by React."
31+
}),
32+
"\n",
33+
jsx("div", {
34+
style: {
35+
backgroundColor: "indigo",
36+
padding: "1rem"
37+
},
38+
onClick: () => setCounter(counter + 1),
39+
children: jsxs("p", {
40+
children: [
41+
"Try changing the background color to ",
42+
jsx("code", {
43+
children: "tomato"
44+
}),
45+
"."
46+
]
47+
})
48+
})
49+
]
50+
});
51+
}
52+
```

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@
5656
"@clack/prompts": "^0.7.0",
5757
"@observablehq/inputs": "^0.10.6",
5858
"@observablehq/runtime": "^5.9.4",
59+
"@rollup/plugin-commonjs": "^25.0.7",
5960
"@rollup/plugin-node-resolve": "^15.2.3",
61+
"@rollup/plugin-virtual": "^3.0.2",
6062
"acorn": "^8.11.2",
6163
"acorn-walk": "^8.3.0",
6264
"ci-info": "^4.0.0",
@@ -118,6 +120,8 @@
118120
"glob": "^10.3.10",
119121
"mocha": "^10.2.0",
120122
"prettier": "^3.0.3 <3.1",
123+
"react": "^18.2.0",
124+
"react-dom": "^18.2.0",
121125
"rimraf": "^5.0.5",
122126
"tempy": "^3.1.0",
123127
"typescript": "^5.2.2",

src/files.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,21 +48,17 @@ export function* visitMarkdownFiles(root: string): Generator<string> {
4848
}
4949
}
5050

51-
/** Yields every file within the given root, recursively. */
51+
/** Yields every file within the given root, recursively, ignoring .observablehq. */
5252
export function* visitFiles(root: string): Generator<string> {
5353
const visited = new Set<number>();
5454
const queue: string[] = [(root = normalize(root))];
55-
try {
56-
visited.add(statSync(join(root, ".observablehq")).ino);
57-
} catch {
58-
// ignore the .observablehq directory, if it exists
59-
}
6055
for (const path of queue) {
6156
const status = statSync(path);
6257
if (status.isDirectory()) {
6358
if (visited.has(status.ino)) continue; // circular symlink
6459
visited.add(status.ino);
6560
for (const entry of readdirSync(path)) {
61+
if (entry === ".observablehq") continue; // ignore the .observablehq directory
6662
queue.push(join(path, entry));
6763
}
6864
} else {

src/node.ts

Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,25 @@ import {createRequire} from "node:module";
44
import op from "node:path";
55
import {extname, join} from "node:path/posix";
66
import {pathToFileURL} from "node:url";
7+
import commonjs from "@rollup/plugin-commonjs";
78
import {nodeResolve} from "@rollup/plugin-node-resolve";
9+
import virtual from "@rollup/plugin-virtual";
810
import {packageDirectory} from "pkg-dir";
911
import type {AstNode, OutputChunk, Plugin, ResolveIdResult} from "rollup";
1012
import {rollup} from "rollup";
1113
import esbuild from "rollup-plugin-esbuild";
1214
import {prepareOutput, toOsPath} from "./files.js";
1315
import type {ImportReference} from "./javascript/imports.js";
1416
import {isJavaScript, parseImports} from "./javascript/imports.js";
15-
import {parseNpmSpecifier} from "./npm.js";
16-
import {isPathImport} from "./path.js";
17+
import {parseNpmSpecifier, rewriteNpmImports} from "./npm.js";
18+
import {isPathImport, relativePath} from "./path.js";
1719
import {faint} from "./tty.js";
1820

1921
export async function resolveNodeImport(root: string, spec: string): Promise<string> {
2022
return resolveNodeImportInternal(op.join(root, ".observablehq", "cache", "_node"), root, spec);
2123
}
2224

23-
const bundlePromises = new Map<string, Promise<void>>();
25+
const bundlePromises = new Map<string, Promise<string>>();
2426

2527
async function resolveNodeImportInternal(cacheRoot: string, packageRoot: string, spec: string): Promise<string> {
2628
const {name, path = "."} = parseNpmSpecifier(spec);
@@ -31,24 +33,23 @@ async function resolveNodeImportInternal(cacheRoot: string, packageRoot: string,
3133
const {version} = JSON.parse(await readFile(op.join(packageResolution, "package.json"), "utf-8"));
3234
const resolution = `${name}@${version}/${extname(path) ? path : path === "." ? "index.js" : `${path}.js`}`;
3335
const outputPath = op.join(cacheRoot, toOsPath(resolution));
34-
if (!existsSync(outputPath)) {
35-
let promise = bundlePromises.get(outputPath);
36-
if (!promise) {
37-
promise = (async () => {
38-
process.stdout.write(`${spec} ${faint("→")} ${resolution}\n`);
39-
await prepareOutput(outputPath);
40-
if (isJavaScript(pathResolution)) {
41-
await writeFile(outputPath, await bundle(spec, cacheRoot, packageResolution));
42-
} else {
43-
await copyFile(pathResolution, outputPath);
44-
}
45-
})();
46-
bundlePromises.set(outputPath, promise);
47-
promise.catch(console.error).then(() => bundlePromises.delete(outputPath));
36+
const resolutionPath = `/_node/${resolution}`;
37+
if (existsSync(outputPath)) return resolutionPath;
38+
let promise = bundlePromises.get(outputPath);
39+
if (promise) return promise; // coalesce concurrent requests
40+
promise = (async () => {
41+
console.log(`${spec} ${faint("→")} ${outputPath}`);
42+
await prepareOutput(outputPath);
43+
if (isJavaScript(pathResolution)) {
44+
await writeFile(outputPath, await bundle(resolutionPath, spec, require, cacheRoot, packageResolution), "utf-8");
45+
} else {
46+
await copyFile(pathResolution, outputPath);
4847
}
49-
await promise;
50-
}
51-
return `/_node/${resolution}`;
48+
return resolutionPath;
49+
})();
50+
promise.catch(console.error).then(() => bundlePromises.delete(outputPath));
51+
bundlePromises.set(outputPath, promise);
52+
return promise;
5253
}
5354

5455
/**
@@ -69,29 +70,59 @@ export function extractNodeSpecifier(path: string): string {
6970
return path.replace(/^\/_node\//, "");
7071
}
7172

72-
async function bundle(input: string, cacheRoot: string, packageRoot: string): Promise<string> {
73+
/**
74+
* React (and its dependencies) are distributed as CommonJS modules, and worse,
75+
* they’re incompatible with cjs-module-lexer; so when we try to import them as
76+
* ES modules we only see a default export. We fix this by creating a shim
77+
* module that exports everything that is visible to require. I hope the React
78+
* team distributes ES modules soon…
79+
*
80+
* https://github.com/facebook/react/issues/11503
81+
*/
82+
function isBadCommonJs(specifier: string): boolean {
83+
const {name} = parseNpmSpecifier(specifier);
84+
return name === "react" || name === "react-dom" || name === "react-is" || name === "scheduler";
85+
}
86+
87+
function shimCommonJs(specifier: string, require: NodeRequire): string {
88+
return `export {${Object.keys(require(specifier))}} from ${JSON.stringify(specifier)};\n`;
89+
}
90+
91+
async function bundle(
92+
path: string,
93+
input: string,
94+
require: NodeRequire,
95+
cacheRoot: string,
96+
packageRoot: string
97+
): Promise<string> {
7398
const bundle = await rollup({
74-
input,
99+
input: isBadCommonJs(input) ? "-" : input,
75100
plugins: [
76-
nodeResolve({browser: true, rootDir: packageRoot}),
101+
...(isBadCommonJs(input) ? [(virtual as any)({"-": shimCommonJs(input, require)})] : []),
77102
importResolve(input, cacheRoot, packageRoot),
103+
nodeResolve({browser: true, rootDir: packageRoot}),
104+
(commonjs as any)({esmExternals: true}),
78105
esbuild({
79106
format: "esm",
80107
platform: "browser",
81108
target: ["es2022", "chrome96", "firefox96", "safari16", "node18"],
82109
exclude: [], // don’t exclude node_modules
110+
define: {"process.env.NODE_ENV": JSON.stringify("production")},
83111
minify: true
84112
})
85113
],
114+
external(source) {
115+
return source.startsWith("/_node/");
116+
},
86117
onwarn(message, warn) {
87118
if (message.code === "CIRCULAR_DEPENDENCY") return;
88119
warn(message);
89120
}
90121
});
91122
try {
92-
const output = await bundle.generate({format: "es"});
93-
const code = output.output.find((o): o is OutputChunk => o.type === "chunk")!.code; // TODO don’t assume one chunk?
94-
return code;
123+
const output = await bundle.generate({format: "es", exports: "named"});
124+
const code = output.output.find((o): o is OutputChunk => o.type === "chunk")!.code;
125+
return rewriteNpmImports(code, (i) => (i.startsWith("/_node/") ? relativePath(path, i) : i));
95126
} finally {
96127
await bundle.close();
97128
}

src/npm.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -75,29 +75,28 @@ const npmRequests = new Map<string, Promise<string>>();
7575
/** Note: path must start with "/_npm/". */
7676
export async function populateNpmCache(root: string, path: string): Promise<string> {
7777
if (!path.startsWith("/_npm/")) throw new Error(`invalid npm path: ${path}`);
78-
const filePath = join(root, ".observablehq", "cache", path);
79-
if (existsSync(filePath)) return filePath;
80-
let promise = npmRequests.get(filePath);
78+
const outputPath = join(root, ".observablehq", "cache", path);
79+
if (existsSync(outputPath)) return outputPath;
80+
let promise = npmRequests.get(outputPath);
8181
if (promise) return promise; // coalesce concurrent requests
82-
promise = (async function () {
82+
promise = (async () => {
8383
const specifier = extractNpmSpecifier(path);
8484
const href = `https://cdn.jsdelivr.net/npm/${specifier}`;
85-
process.stdout.write(`npm:${specifier} ${faint("→")} `);
85+
console.log(`npm:${specifier} ${faint("→")} ${outputPath}`);
8686
const response = await fetch(href);
8787
if (!response.ok) throw new Error(`unable to fetch: ${href}`);
88-
process.stdout.write(`${filePath}\n`);
89-
await mkdir(dirname(filePath), {recursive: true});
88+
await mkdir(dirname(outputPath), {recursive: true});
9089
if (/^application\/javascript(;|$)/i.test(response.headers.get("content-type")!)) {
9190
const source = await response.text();
9291
const resolver = await getDependencyResolver(root, path, source);
93-
await writeFile(filePath, rewriteNpmImports(source, resolver), "utf-8");
92+
await writeFile(outputPath, rewriteNpmImports(source, resolver), "utf-8");
9493
} else {
95-
await writeFile(filePath, Buffer.from(await response.arrayBuffer()));
94+
await writeFile(outputPath, Buffer.from(await response.arrayBuffer()));
9695
}
97-
return filePath;
96+
return outputPath;
9897
})();
99-
promise.catch(() => {}).then(() => npmRequests.delete(filePath));
100-
npmRequests.set(filePath, promise);
98+
promise.catch(console.error).then(() => npmRequests.delete(outputPath));
99+
npmRequests.set(outputPath, promise);
101100
return promise;
102101
}
103102

test/files-test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ describe("visitFiles(root)", () => {
6262
if (os.platform() === "win32") this.skip(); // symlinks are not the same on Windows
6363
assert.deepStrictEqual(collect(visitFiles("test/input/circular-files")), ["a/a.txt", "b/b.txt"]);
6464
});
65+
it("ignores .observablehq at any level", function () {
66+
assert.deepStrictEqual(collect(visitFiles("test/files")), ["visible.txt", "sub/visible.txt"]);
67+
});
6568
});
6669

6770
describe("visitMarkdownFiles(root)", () => {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
This file shouldn’t be found by visitFiles.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
This file shouldn’t be found by visitFiles.

test/files/sub/visible.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
This file should be found by visitFiles.

test/files/visible.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
This file should be found by visitFiles.

0 commit comments

Comments
 (0)