Skip to content

Commit 0c72304

Browse files
authored
JSX + React (#1429)
* JSX * more docs * doc edits * fix incremental update * don’t clear jsx pre-emptively * ReactDOM, too * more docs * jsx test * prerelease badge * move jsx higher
1 parent 5d67302 commit 0c72304

File tree

114 files changed

+826
-248
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

114 files changed

+826
-248
lines changed

docs/.eslintrc.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
{
2+
"resolve": {
3+
"extensions": [".js", ".jsx"]
4+
},
25
"env": {
36
"browser": true
47
}

docs/components/Card.jsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export function Card({title, children} = {}) {
2+
return (
3+
<div className="card">
4+
{title ? <h2>{title}</h2> : null}
5+
{children}
6+
</div>
7+
);
8+
}

docs/jsx.md

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# JSX <a href="https://github.com/observablehq/framework/pull/1429" class="observablehq-version-badge" data-version="prerelease" title="Added in #1429"></a>
2+
3+
[React](https://react.dev/) is a popular and powerful library for building interactive interfaces. React is typically written in [JSX](https://react.dev/learn/writing-markup-with-jsx), an extension of JavaScript that allows HTML-like markup. To use JSX and React, declare a JSX fenced code block (<code>```jsx</code>). For example, to define a `Greeting` component that accepts a `subject` prop:
4+
5+
````md
6+
```jsx
7+
function Greeting({subject}) {
8+
return <div>Hello, <b>{subject}</b>!</div>
9+
}
10+
```
11+
````
12+
13+
```jsx
14+
function Greeting({subject}) {
15+
return <div>Hello, <b>{subject}</b>!</div>
16+
}
17+
```
18+
19+
Then call the built-in display function to render content:
20+
21+
```jsx echo
22+
display(<Greeting subject="JSX" />);
23+
```
24+
25+
You can combine React with Framework’s built-in [reactivity](./reactivity) by passing reactive values as props. Try changing the `name` below.
26+
27+
```jsx echo
28+
display(<Greeting subject={name || "anonymous"} />);
29+
```
30+
31+
```js echo
32+
const name = view(Inputs.text({label: "Name", placeholder: "Anonymous"}));
33+
```
34+
35+
You can use hooks such as [`useState`](https://react.dev/reference/react/useState), [`useEffect`](https://react.dev/reference/react/useEffect), and [`useRef`](https://react.dev/reference/react/useRef). The `Counter` component below counts the number of times you click the button.
36+
37+
```jsx echo
38+
function Counter() {
39+
const [count, setCount] = React.useState(0);
40+
return (
41+
<button onClick={() => setCount(count + 1)}>
42+
You clicked {count} times
43+
</button>
44+
);
45+
}
46+
```
47+
48+
```jsx echo
49+
display(<Counter />);
50+
```
51+
52+
React is available by default as `React` in Markdown, but you can import it explicitly like so:
53+
54+
```js run=false
55+
import * as React from "npm:react";
56+
```
57+
58+
If you prefer, you can import specific symbols, such as hooks:
59+
60+
```js run=false
61+
import {useState} from "npm:react";
62+
```
63+
64+
React DOM is also available as `ReactDOM` in Markdown, or can be imported as:
65+
66+
```js run=false
67+
import * as ReactDOM from "npm:react-dom";
68+
```
69+
70+
You can define components in JSX modules. For example, if this were `components/Card.jsx`:
71+
72+
```jsx run=false
73+
export function Card({title, children} = {}) {
74+
return (
75+
<div className="card">
76+
{title ? <h2>{title}</h2> : null}
77+
{children}
78+
</div>
79+
);
80+
}
81+
```
82+
83+
You could then import the `Card` component as:
84+
85+
```js echo
86+
import {Card} from "./components/Card.js";
87+
```
88+
89+
<div class="note">
90+
91+
Use the `.js` file extension when importing JSX (`.jsx`) modules; JSX is transpiled to JavaScript during build.
92+
93+
</div>
94+
95+
And, as before, you can render a card using the display function:
96+
97+
```jsx echo
98+
display(<Card title="A test of cards">If you can read this, success!</Card>);
99+
```
100+
101+
Within a JSX fenced code block, the [display function](./javascript#explicit-display) behaves a bit differently from a JavaScript fenced code block or inline expression:
102+
it replaces the previously-displayed content, if any. In addition, JSX fenced code blocks do not support implicit display; content can only be displayed explicitly.
103+
104+
<div class="note">
105+
106+
In the future we intend to support other JSX-compatible frameworks, such as Preact. We are also working on server-side rendering with client-side hydration; please upvote [#931](https://github.com/observablehq/framework/issues/931) if you are interested in this feature.
107+
108+
</div>
109+
110+
## Inline expressions
111+
112+
JSX is not currently supported in inline expression `${…}`; only JavaScript is allowed in inline expressions. However, you can declare a detached root using [`createRoot`](https://react.dev/reference/react-dom/client/createRoot):
113+
114+
```js echo
115+
const node = document.createElement("SPAN");
116+
const root = ReactDOM.createRoot(node);
117+
```
118+
119+
Then use a JSX code block to render the desired content into the root:
120+
121+
```jsx echo
122+
root.render(<>Hello, <i>{name || "anonymous"}</i>!</>);
123+
```
124+
125+
Lastly, interpolate the root into the desired location with an inline expression:
126+
127+
<div class="card">
128+
<h2>Rendering into an inline expression</h2>
129+
${node}
130+
</div>
131+
132+
```md run=false
133+
<div class="card">
134+
<h2>Rendering into an inline expression</h2>
135+
${node}
136+
</div>
137+
```

observablehq.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export default {
2222
{name: "Markdown", path: "/markdown"},
2323
{name: "JavaScript", path: "/javascript"},
2424
{name: "Reactivity", path: "/reactivity"},
25+
{name: "JSX", path: "/jsx"},
2526
{name: "Imports", path: "/imports"},
2627
{name: "Data loaders", path: "/loaders"},
2728
{name: "Files", path: "/files"},

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"observable": "dist/bin/observable.js"
2020
},
2121
"scripts": {
22-
"dev": "rimraf --glob docs/themes.md docs/theme/*.md && (tsx watch docs/theme/generate-themes.ts & tsx watch --no-warnings=ExperimentalWarning ./src/bin/observable.ts preview --no-open)",
22+
"dev": "rimraf --glob docs/themes.md docs/theme/*.md && (tsx watch docs/theme/generate-themes.ts & tsx watch --ignore docs --no-warnings=ExperimentalWarning ./src/bin/observable.ts preview --no-open)",
2323
"docs:themes": "rimraf --glob docs/themes.md docs/theme/*.md && tsx docs/theme/generate-themes.ts",
2424
"docs:build": "yarn docs:themes && rimraf docs/.observablehq/dist && tsx --no-warnings=ExperimentalWarning ./src/bin/observable.ts build",
2525
"docs:deploy": "yarn docs:themes && tsx --no-warnings=ExperimentalWarning ./src/bin/observable.ts deploy",

src/client/main.js

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const cellsById = new Map();
2323
const rootsById = findRoots(document.body);
2424

2525
export function define(cell) {
26-
const {id, inline, inputs = [], outputs = [], body} = cell;
26+
const {id, mode, inputs = [], outputs = [], body} = cell;
2727
const variables = [];
2828
cellsById.set(id, {cell, variables});
2929
const root = rootsById.get(id);
@@ -35,15 +35,16 @@ export function define(cell) {
3535
const v = main.variable({_node: root.parentNode, pending, rejected}, {shadow: {}}); // _node for visibility promise
3636
if (inputs.includes("display") || inputs.includes("view")) {
3737
let displayVersion = -1; // the variable._version of currently-displayed values
38-
const display = inline ? displayInline : displayBlock;
38+
const predisplay = mode === "jsx" ? noop : clear; // jsx replaces previous display naturally
39+
const display = mode === "inline" ? displayInline : mode === "jsx" ? displayJsx : displayBlock;
3940
const vd = new v.constructor(2, v._module);
4041
vd.define(
4142
inputs.filter((i) => i !== "display" && i !== "view"),
4243
() => {
4344
let version = v._version; // capture version on input change
4445
return (value) => {
4546
if (version < displayVersion) throw new Error("stale display");
46-
else if (version > displayVersion) clear(root);
47+
else if (version > displayVersion) predisplay(root);
4748
displayVersion = version;
4849
display(root, value);
4950
return value;
@@ -63,6 +64,13 @@ export function define(cell) {
6364
for (const o of outputs) variables.push(main.variable(true).define(o, [`cell ${id}`], (exports) => exports[o]));
6465
}
6566

67+
function noop() {}
68+
69+
function clear(root) {
70+
for (const v of root._nodes) v.remove();
71+
root._nodes.length = 0;
72+
}
73+
6674
// If the variable previously rejected, it will show an error even if it doesn’t
6775
// normally display; we can’t rely on a subsequent display clearing the error,
6876
// so we clear the error when the variable is pending. We also restore the
@@ -83,6 +91,19 @@ function reject(root, error) {
8391
displayNode(root, inspectError(error));
8492
}
8593

94+
function displayJsx(root, value) {
95+
return (root._root ??= import("npm:react-dom/client").then(({createRoot}) => {
96+
const node = document.createElement("DIV");
97+
return [node, createRoot(node)];
98+
})).then(([node, client]) => {
99+
if (!node.parentNode) {
100+
root._nodes.push(node);
101+
root.parentNode.insertBefore(node, root);
102+
}
103+
client.render(value);
104+
});
105+
}
106+
86107
function displayNode(root, node) {
87108
if (node.nodeType === 11) {
88109
let child;
@@ -96,11 +117,6 @@ function displayNode(root, node) {
96117
}
97118
}
98119

99-
function clear(root) {
100-
for (const v of root._nodes) v.remove();
101-
root._nodes.length = 0;
102-
}
103-
104120
function displayInline(root, value) {
105121
if (isNode(value)) {
106122
displayNode(root, value);

src/client/stdlib/recommendedLibraries.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export const L = () => import("npm:leaflet");
1414
export const mapboxgl = () => import("npm:mapbox-gl").then((module) => module.default);
1515
export const mermaid = () => import("observablehq:stdlib/mermaid").then((mermaid) => mermaid.default);
1616
export const Plot = () => import("npm:@observablehq/plot");
17+
export const React = () => import("npm:react");
18+
export const ReactDOM = () => import("npm:react-dom");
1719
export const sql = () => import("observablehq:stdlib/duckdb").then((duckdb) => duckdb.sql);
1820
export const SQLite = () => import("observablehq:stdlib/sqlite").then((sqlite) => sqlite.default);
1921
export const SQLiteDatabaseClient = () => import("observablehq:stdlib/sqlite").then((sqlite) => sqlite.SQLiteDatabaseClient); // prettier-ignore

src/dataloader.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,12 @@ export class LoaderResolver {
125125

126126
getWatchPath(path: string): string | undefined {
127127
const exactPath = join(this.root, path);
128-
return existsSync(exactPath) ? exactPath : this.find(path)?.path;
128+
if (existsSync(exactPath)) return exactPath;
129+
if (exactPath.endsWith(".js")) {
130+
const jsxPath = exactPath + "x";
131+
if (existsSync(jsxPath)) return jsxPath;
132+
}
133+
return this.find(path)?.path;
129134
}
130135

131136
watchFiles(path: string, watchPaths: Iterable<string>, callback: (name: string) => void) {

src/javascript/module.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {createHash} from "node:crypto";
2-
import {accessSync, constants, readFileSync, statSync} from "node:fs";
2+
import {accessSync, constants, existsSync, readFileSync, statSync} from "node:fs";
3+
import {readFile} from "node:fs/promises";
34
import {join} from "node:path/posix";
45
import type {Program} from "acorn";
6+
import {transform, transformSync} from "esbuild";
57
import {resolvePath} from "../path.js";
68
import {findFiles} from "./files.js";
79
import {findImports} from "./imports.js";
@@ -73,7 +75,7 @@ export function getModuleInfo(root: string, path: string): ModuleInfo | undefine
7375
const key = join(root, path);
7476
let mtimeMs: number;
7577
try {
76-
({mtimeMs} = statSync(key));
78+
({mtimeMs} = statSync(resolveJsx(key) ?? key));
7779
} catch {
7880
moduleInfoCache.delete(key); // delete stale entry
7981
return; // ignore missing file
@@ -83,7 +85,7 @@ export function getModuleInfo(root: string, path: string): ModuleInfo | undefine
8385
let source: string;
8486
let body: Program;
8587
try {
86-
source = readFileSync(key, "utf-8");
88+
source = readJavaScriptSync(key);
8789
body = parseProgram(source);
8890
} catch {
8991
moduleInfoCache.delete(key); // delete stale entry
@@ -157,3 +159,37 @@ export function getFileInfo(root: string, path: string): FileInfo | undefined {
157159
}
158160
return entry;
159161
}
162+
163+
function resolveJsx(path: string): string | null {
164+
return !existsSync(path) && path.endsWith(".js") && existsSync((path += "x")) ? path : null;
165+
}
166+
167+
export async function readJavaScript(path: string): Promise<string> {
168+
const jsxPath = resolveJsx(path);
169+
if (jsxPath !== null) {
170+
const source = await readFile(jsxPath, "utf-8");
171+
const {code} = await transform(source, {
172+
loader: "jsx",
173+
jsx: "automatic",
174+
jsxImportSource: "npm:react",
175+
sourcefile: jsxPath
176+
});
177+
return code;
178+
}
179+
return await readFile(path, "utf-8");
180+
}
181+
182+
export function readJavaScriptSync(path: string): string {
183+
const jsxPath = resolveJsx(path);
184+
if (jsxPath !== null) {
185+
const source = readFileSync(jsxPath, "utf-8");
186+
const {code} = transformSync(source, {
187+
loader: "jsx",
188+
jsx: "automatic",
189+
jsxImportSource: "npm:react",
190+
sourcefile: jsxPath
191+
});
192+
return code;
193+
}
194+
return readFileSync(path, "utf-8");
195+
}

src/javascript/parse.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {syntaxError} from "./syntaxError.js";
1313
export interface ParseOptions {
1414
/** The path to the source within the source root. */
1515
path: string;
16-
/** If true, treat the input as an inline expression instead of a fenced code block. */
16+
/** If true, require the input to be an expresssion. */
1717
inline?: boolean;
1818
}
1919

@@ -30,7 +30,6 @@ export interface JavaScriptNode {
3030
imports: ImportReference[];
3131
expression: boolean; // is this an expression or a program cell?
3232
async: boolean; // does this use top-level await?
33-
inline: boolean;
3433
input: string;
3534
}
3635

@@ -57,7 +56,6 @@ export function parseJavaScript(input: string, options: ParseOptions): JavaScrip
5756
imports: findImports(body, path, input),
5857
expression: !!expression,
5958
async: findAwaits(body).length > 0,
60-
inline,
6159
input
6260
};
6361
}

0 commit comments

Comments
 (0)