Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JSX + React #1429

Merged
merged 10 commits into from
Jun 8, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
);
}
23 changes: 23 additions & 0 deletions docs/components/Counter.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {useState} from "npm:react";
import {Card} from "./Card.js";

export function Counter({title = "Untitled"} = {}) {
const [counter, setCounter] = useState(0);
return (
<Card title={title}>
<p>
<button onClick={() => setCounter(counter + 1)}>Click me</button>
</p>
<p>The current count is {counter}.</p>
<div
style={{
transition: "background 250ms ease",
backgroundColor: counter & 1 ? "brown" : "steelblue",
padding: "1rem"
}}
>
This element has a background color that changes.
</div>
</Card>
);
}
101 changes: 101 additions & 0 deletions docs/jsx.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# JSX

Framework supports [React](https://react.dev/) and [JSX](https://react.dev/learn/writing-markup-with-jsx). This provides a convenient way of writing dynamic HTML in JavaScript, using React to manage state and rendering. To use JSX, declare a JSX fenced code block (<code>```jsx</code>), and then call the built-in display function to display some content.

````md
```jsx
display(<i>Hello, <b>JSX</b>!</i>);
```
````

This produces:

```jsx
display(<i>Hello, <b>JSX</b>!</i>);
```

JSX is especially convenient for authoring reusable components. These components are typically imported from JSX modules (`.jsx`), but you can also declare them within JSX fenced code blocks.

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

Naturally, you can combine JSX with Framework’s built-in reactivity. This is typically done by passing in reactive values as props. Try changing the `name` below.

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

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

Below we import a JSX component and render it.

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

display(<Counter title="Hello, JSX" />);
```

With a JSX fenced code block, the [display function](./javascript#explicit-display) behaves a bit differently:

- It replaces the previously-displayed content, if any
- It never uses the inspector

In addition, JSX fenced code blocks should always display explicitly; JSX fenced code blocks do not support implicit display of expressions.

You don’t need to import `React` when using JSX; `React` is implicitly imported. `React` is also available by default in Markdown. If you need to, you can import it explicitly:
mbostock marked this conversation as resolved.
Show resolved Hide resolved

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

You can also import specific symbols such as hooks:

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

Always use the `.js` extension to import JSX modules.
mbostock marked this conversation as resolved.
Show resolved Hide resolved

```jsx run=false
import {useState} from "npm:react";
import {Card} from "./Card.js";

export function Counter({title = "Untitled"} = {}) {
const [counter, setCounter] = useState(0);
return (
<Card title={title}>
<p>
<button onClick={() => setCounter(counter + 1)}>Click me</button>
</p>
<p>The current count is {counter}.</p>
<div
style={{
transition: "background 250ms ease",
backgroundColor: counter & 1 ? "brown" : "steelblue",
mbostock marked this conversation as resolved.
Show resolved Hide resolved
padding: "1rem"
}}
>
This element has a background color that changes.
</div>
</Card>
);
}
```

JSX components can import other JSX components. The component above imports `Card.jsx`, which looks like this:

```jsx run=false
export function Card({title, children} = {}) {
return (
<div className="card">
{title ? <h2>{title}</h2> : null}
{children}
</div>
);
}
```
1 change: 1 addition & 0 deletions observablehq.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export default {
{name: "Data loaders", path: "/loaders"},
{name: "Files", path: "/files"},
{name: "SQL", path: "/sql"},
{name: "JSX", path: "/jsx"},
{name: "Themes", path: "/themes"},
{name: "Configuration", path: "/config"},
{name: "Deploying", path: "/deploying"},
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
12 changes: 10 additions & 2 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,7 +35,7 @@ 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 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"),
Expand Down Expand Up @@ -83,6 +83,14 @@ function reject(root, error) {
displayNode(root, inspectError(error));
}

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

function displayNode(root, node) {
if (node.nodeType === 11) {
let child;
Expand Down
1 change: 1 addition & 0 deletions src/client/stdlib/recommendedLibraries.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ 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 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
16 changes: 14 additions & 2 deletions src/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable import/no-named-as-default-member */
import {createHash} from "node:crypto";
import {transformSync} from "esbuild";
import he from "he";
import MarkdownIt from "markdown-it";
import type {Token} from "markdown-it";
Expand All @@ -25,6 +26,7 @@ import {red} from "./tty.js";
export interface MarkdownCode {
id: string;
node: JavaScriptNode;
mode: "inline" | "block" | "jsx";
}

export interface MarkdownPage {
Expand Down Expand Up @@ -57,9 +59,19 @@ function isFalse(attribute: string | undefined): boolean {
return attribute?.toLowerCase() === "false";
}

function transformJsx(content: string): string {
try {
return transformSync(content, {loader: "jsx", jsx: "automatic", jsxImportSource: "npm:react"}).code;
} catch (error: any) {
throw new SyntaxError(error.message);
}
}

function getLiveSource(content: string, tag: string, attributes: Record<string, string>): string | undefined {
return tag === "js"
? content
: tag === "jsx"
? transformJsx(content)
: tag === "tex"
? transpileTag(content, "tex.block", true)
: tag === "html"
Expand Down Expand Up @@ -107,7 +119,7 @@ function makeFenceRenderer(baseRenderer: RenderRule): RenderRule {
const id = uniqueCodeId(context, source);
// TODO const sourceLine = context.startLine + context.currentLine;
const node = parseJavaScript(source, {path});
context.code.push({id, node});
context.code.push({id, node, mode: tag === "jsx" ? "jsx" : "block"});
html += `<div class="observablehq observablehq--block">${
node.expression ? "<observablehq-loading></observablehq-loading>" : ""
}<!--:${id}:--></div>\n`;
Expand Down Expand Up @@ -173,7 +185,7 @@ function makePlaceholderRenderer(): RenderRule {
try {
// TODO sourceLine: context.startLine + context.currentLine
const node = parseJavaScript(token.content, {path, inline: true});
context.code.push({id, node});
context.code.push({id, node, mode: "inline"});
return `<observablehq-loading></observablehq-loading><!--:${id}:-->`;
} catch (error) {
if (!(error instanceof SyntaxError)) throw error;
Expand Down
Loading