Skip to content

Commit

Permalink
JSX + React (#1429)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
mbostock authored Jun 8, 2024
1 parent 5d67302 commit 0c72304
Show file tree
Hide file tree
Showing 114 changed files with 826 additions and 248 deletions.
3 changes: 3 additions & 0 deletions docs/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{
"resolve": {
"extensions": [".js", ".jsx"]
},
"env": {
"browser": true
}
Expand Down
8 changes: 8 additions & 0 deletions docs/components/Card.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function Card({title, children} = {}) {
return (
<div className="card">
{title ? <h2>{title}</h2> : null}
{children}
</div>
);
}
137 changes: 137 additions & 0 deletions docs/jsx.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# JSX <a href="https://github.com/observablehq/framework/pull/1429" class="observablehq-version-badge" data-version="prerelease" title="Added in #1429"></a>

[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:

````md
```jsx
function Greeting({subject}) {
return <div>Hello, <b>{subject}</b>!</div>
}
```
````

```jsx
function Greeting({subject}) {
return <div>Hello, <b>{subject}</b>!</div>
}
```

Then call the built-in display function to render content:

```jsx echo
display(<Greeting subject="JSX" />);
```

You can combine React with Framework’s built-in [reactivity](./reactivity) by passing reactive values as props. Try changing the `name` below.

```jsx echo
display(<Greeting subject={name || "anonymous"} />);
```

```js echo
const name = view(Inputs.text({label: "Name", placeholder: "Anonymous"}));
```

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.

```jsx echo
function Counter() {
const [count, setCount] = React.useState(0);
return (
<button onClick={() => setCount(count + 1)}>
You clicked {count} times
</button>
);
}
```

```jsx echo
display(<Counter />);
```

React is available by default as `React` in Markdown, but you can import it explicitly like so:

```js run=false
import * as React from "npm:react";
```

If you prefer, you can import specific symbols, such as hooks:

```js run=false
import {useState} from "npm:react";
```

React DOM is also available as `ReactDOM` in Markdown, or can be imported as:

```js run=false
import * as ReactDOM from "npm:react-dom";
```

You can define components in JSX modules. For example, if this were `components/Card.jsx`:

```jsx run=false
export function Card({title, children} = {}) {
return (
<div className="card">
{title ? <h2>{title}</h2> : null}
{children}
</div>
);
}
```

You could then import the `Card` component as:

```js echo
import {Card} from "./components/Card.js";
```

<div class="note">

Use the `.js` file extension when importing JSX (`.jsx`) modules; JSX is transpiled to JavaScript during build.

</div>

And, as before, you can render a card using the display function:

```jsx echo
display(<Card title="A test of cards">If you can read this, success!</Card>);
```

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:
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.

<div class="note">

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.

</div>

## Inline expressions

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):

```js echo
const node = document.createElement("SPAN");
const root = ReactDOM.createRoot(node);
```

Then use a JSX code block to render the desired content into the root:

```jsx echo
root.render(<>Hello, <i>{name || "anonymous"}</i>!</>);
```

Lastly, interpolate the root into the desired location with an inline expression:

<div class="card">
<h2>Rendering into an inline expression</h2>
${node}
</div>

```md run=false
<div class="card">
<h2>Rendering into an inline expression</h2>
${node}
</div>
```
1 change: 1 addition & 0 deletions observablehq.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export default {
{name: "Markdown", path: "/markdown"},
{name: "JavaScript", path: "/javascript"},
{name: "Reactivity", path: "/reactivity"},
{name: "JSX", path: "/jsx"},
{name: "Imports", path: "/imports"},
{name: "Data loaders", path: "/loaders"},
{name: "Files", path: "/files"},
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"observable": "dist/bin/observable.js"
},
"scripts": {
"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)",
"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)",
"docs:themes": "rimraf --glob docs/themes.md docs/theme/*.md && tsx docs/theme/generate-themes.ts",
"docs:build": "yarn docs:themes && rimraf docs/.observablehq/dist && tsx --no-warnings=ExperimentalWarning ./src/bin/observable.ts build",
"docs:deploy": "yarn docs:themes && tsx --no-warnings=ExperimentalWarning ./src/bin/observable.ts deploy",
Expand Down
32 changes: 24 additions & 8 deletions src/client/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const cellsById = new Map();
const rootsById = findRoots(document.body);

export function define(cell) {
const {id, inline, inputs = [], outputs = [], body} = cell;
const {id, mode, inputs = [], outputs = [], body} = cell;
const variables = [];
cellsById.set(id, {cell, variables});
const root = rootsById.get(id);
Expand All @@ -35,15 +35,16 @@ export function define(cell) {
const v = main.variable({_node: root.parentNode, pending, rejected}, {shadow: {}}); // _node for visibility promise
if (inputs.includes("display") || inputs.includes("view")) {
let displayVersion = -1; // the variable._version of currently-displayed values
const display = inline ? displayInline : displayBlock;
const predisplay = mode === "jsx" ? noop : clear; // jsx replaces previous display naturally
const display = mode === "inline" ? displayInline : mode === "jsx" ? displayJsx : displayBlock;
const vd = new v.constructor(2, v._module);
vd.define(
inputs.filter((i) => i !== "display" && i !== "view"),
() => {
let version = v._version; // capture version on input change
return (value) => {
if (version < displayVersion) throw new Error("stale display");
else if (version > displayVersion) clear(root);
else if (version > displayVersion) predisplay(root);
displayVersion = version;
display(root, value);
return value;
Expand All @@ -63,6 +64,13 @@ export function define(cell) {
for (const o of outputs) variables.push(main.variable(true).define(o, [`cell ${id}`], (exports) => exports[o]));
}

function noop() {}

function clear(root) {
for (const v of root._nodes) v.remove();
root._nodes.length = 0;
}

// If the variable previously rejected, it will show an error even if it doesn’t
// normally display; we can’t rely on a subsequent display clearing the error,
// so we clear the error when the variable is pending. We also restore the
Expand All @@ -83,6 +91,19 @@ function reject(root, error) {
displayNode(root, inspectError(error));
}

function displayJsx(root, value) {
return (root._root ??= import("npm:react-dom/client").then(({createRoot}) => {
const node = document.createElement("DIV");
return [node, createRoot(node)];
})).then(([node, client]) => {
if (!node.parentNode) {
root._nodes.push(node);
root.parentNode.insertBefore(node, root);
}
client.render(value);
});
}

function displayNode(root, node) {
if (node.nodeType === 11) {
let child;
Expand All @@ -96,11 +117,6 @@ function displayNode(root, node) {
}
}

function clear(root) {
for (const v of root._nodes) v.remove();
root._nodes.length = 0;
}

function displayInline(root, value) {
if (isNode(value)) {
displayNode(root, value);
Expand Down
2 changes: 2 additions & 0 deletions src/client/stdlib/recommendedLibraries.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export const L = () => import("npm:leaflet");
export const mapboxgl = () => import("npm:mapbox-gl").then((module) => module.default);
export const mermaid = () => import("observablehq:stdlib/mermaid").then((mermaid) => mermaid.default);
export const Plot = () => import("npm:@observablehq/plot");
export const React = () => import("npm:react");
export const ReactDOM = () => import("npm:react-dom");
export const sql = () => import("observablehq:stdlib/duckdb").then((duckdb) => duckdb.sql);
export const SQLite = () => import("observablehq:stdlib/sqlite").then((sqlite) => sqlite.default);
export const SQLiteDatabaseClient = () => import("observablehq:stdlib/sqlite").then((sqlite) => sqlite.SQLiteDatabaseClient); // prettier-ignore
Expand Down
7 changes: 6 additions & 1 deletion src/dataloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,12 @@ export class LoaderResolver {

getWatchPath(path: string): string | undefined {
const exactPath = join(this.root, path);
return existsSync(exactPath) ? exactPath : this.find(path)?.path;
if (existsSync(exactPath)) return exactPath;
if (exactPath.endsWith(".js")) {
const jsxPath = exactPath + "x";
if (existsSync(jsxPath)) return jsxPath;
}
return this.find(path)?.path;
}

watchFiles(path: string, watchPaths: Iterable<string>, callback: (name: string) => void) {
Expand Down
42 changes: 39 additions & 3 deletions src/javascript/module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {createHash} from "node:crypto";
import {accessSync, constants, readFileSync, statSync} from "node:fs";
import {accessSync, constants, existsSync, readFileSync, statSync} from "node:fs";
import {readFile} from "node:fs/promises";
import {join} from "node:path/posix";
import type {Program} from "acorn";
import {transform, transformSync} from "esbuild";
import {resolvePath} from "../path.js";
import {findFiles} from "./files.js";
import {findImports} from "./imports.js";
Expand Down Expand Up @@ -73,7 +75,7 @@ export function getModuleInfo(root: string, path: string): ModuleInfo | undefine
const key = join(root, path);
let mtimeMs: number;
try {
({mtimeMs} = statSync(key));
({mtimeMs} = statSync(resolveJsx(key) ?? key));
} catch {
moduleInfoCache.delete(key); // delete stale entry
return; // ignore missing file
Expand All @@ -83,7 +85,7 @@ export function getModuleInfo(root: string, path: string): ModuleInfo | undefine
let source: string;
let body: Program;
try {
source = readFileSync(key, "utf-8");
source = readJavaScriptSync(key);
body = parseProgram(source);
} catch {
moduleInfoCache.delete(key); // delete stale entry
Expand Down Expand Up @@ -157,3 +159,37 @@ export function getFileInfo(root: string, path: string): FileInfo | undefined {
}
return entry;
}

function resolveJsx(path: string): string | null {
return !existsSync(path) && path.endsWith(".js") && existsSync((path += "x")) ? path : null;
}

export async function readJavaScript(path: string): Promise<string> {
const jsxPath = resolveJsx(path);
if (jsxPath !== null) {
const source = await readFile(jsxPath, "utf-8");
const {code} = await transform(source, {
loader: "jsx",
jsx: "automatic",
jsxImportSource: "npm:react",
sourcefile: jsxPath
});
return code;
}
return await readFile(path, "utf-8");
}

export function readJavaScriptSync(path: string): string {
const jsxPath = resolveJsx(path);
if (jsxPath !== null) {
const source = readFileSync(jsxPath, "utf-8");
const {code} = transformSync(source, {
loader: "jsx",
jsx: "automatic",
jsxImportSource: "npm:react",
sourcefile: jsxPath
});
return code;
}
return readFileSync(path, "utf-8");
}
4 changes: 1 addition & 3 deletions src/javascript/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {syntaxError} from "./syntaxError.js";
export interface ParseOptions {
/** The path to the source within the source root. */
path: string;
/** If true, treat the input as an inline expression instead of a fenced code block. */
/** If true, require the input to be an expresssion. */
inline?: boolean;
}

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

Expand All @@ -57,7 +56,6 @@ export function parseJavaScript(input: string, options: ParseOptions): JavaScrip
imports: findImports(body, path, input),
expression: !!expression,
async: findAwaits(body).length > 0,
inline,
input
};
}
Expand Down
5 changes: 3 additions & 2 deletions src/javascript/transpile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ import {getStringLiteralValue, isStringLiteral} from "./source.js";
export interface TranspileOptions {
id: string;
path: string;
mode?: string;
resolveImport?: (specifier: string) => string;
}

export function transpileJavaScript(node: JavaScriptNode, {id, path, resolveImport}: TranspileOptions): string {
export function transpileJavaScript(node: JavaScriptNode, {id, path, mode, resolveImport}: TranspileOptions): string {
let async = node.async;
const inputs = Array.from(new Set<string>(node.references.map((r) => r.name)));
const outputs = Array.from(new Set<string>(node.declarations?.map((r) => r.name)));
Expand All @@ -35,7 +36,7 @@ export function transpileJavaScript(node: JavaScriptNode, {id, path, resolveImpo
output.insertLeft(0, `, body: ${async ? "async " : ""}(${inputs}) => {\n`);
if (outputs.length) output.insertLeft(0, `, outputs: ${JSON.stringify(outputs)}`);
if (inputs.length) output.insertLeft(0, `, inputs: ${JSON.stringify(inputs)}`);
if (node.inline) output.insertLeft(0, ", inline: true");
if (mode && mode !== "block") output.insertLeft(0, `, mode: ${JSON.stringify(mode)}`);
output.insertLeft(0, `define({id: ${JSON.stringify(id)}`);
if (outputs.length) output.insertRight(node.input.length, `\nreturn {${outputs}};`);
output.insertRight(node.input.length, "\n}});\n");
Expand Down
Loading

0 comments on commit 0c72304

Please sign in to comment.