diff --git a/docs/syntax-jsx-support.md b/docs/syntax-jsx-support.md new file mode 100644 index 000000000..f6c6e5e72 --- /dev/null +++ b/docs/syntax-jsx-support.md @@ -0,0 +1,338 @@ +# JSX syntax for Luau via `.luax` (experimental) + +## Summary + +This RFC proposes adding an **optional JSX-like syntax** to Luau, enabled only for files with a `.luax` extension and gated behind a feature flag, that desugars directly into existing Luau AST constructs (no separate transpile step). + +## Motivation + +JSX is a concise, widely understood syntax for describing UI trees. In Roblox’s ecosystem, UI is commonly built using React-like libraries (e.g. Roact/React), but Luau today requires verbose `createElement(...)` calls or helper builders. + +This proposal aims to: + +- Make UI code **more readable** and **more ergonomic**. +- Preserve Luau’s “no mandatory build step” workflow by implementing parsing in the language front-end. +- Avoid hard-coding any specific UI library into the language by desugaring to a **user-provided factory function**. + +### Why JSX belongs at the language level (and is not “React syntax”) + +A common concern is that JSX is “too specific” to React or UI frameworks to warrant native syntax. This RFC argues the opposite: **JSX is a general-purpose notation for declarative, structured function calls**, and the runtime meaning is entirely controlled by the `jsx` factory function chosen by the host/project. + +In other words, JSX is not “React in the language”; it is: + +- A compact syntax for constructing nested call trees (`jsx(tag, props, ...children)`). +- A convenient way to bind named parameters via record-like props (`{ key = value }`). +- A structured form that is easier for humans and tools to read than deeply nested function calls. + +This makes JSX useful beyond UI, including data-driven scene graphs, entity graphs, or declarative configuration trees. + +## Design + +### Goals + +- **No transpilation step**: JSX is parsed directly by Luau’s parser when enabled. +- **Opt-in**: no behavior change for `.lua` / `.luau` unless explicitly enabled. +- **Library-agnostic**: JSX lowers to an ordinary Luau call (`jsx(...)`) so projects can bind it to Roact/React/etc. +- **Minimize grammar ambiguity** with existing and future Luau syntax. + +### Non-goals (initially) + +These are intentionally excluded from the initial proposal to reduce ambiguity and implementation risk: + +- Raw text children (e.g. `hello`) +- Fragments (e.g. `<>...`) +- Spread attributes (e.g. ``) +- Namespaced attributes (e.g. `on:click=...`) +- JSX comments syntax + +These can be added via follow-on RFCs once the core parsing approach proves robust. + +### Opt-in surface area + +**File extension**: `.luax` indicates JSX may appear in the file. + +**Feature flag**: The syntax must be gated by a flag (e.g. `FFlag::LuauJsx`) so it can ship experimentally and be rolled out carefully. + +### Syntax + +When enabled, an additional `simpleexp` form is introduced: + +- `jsxElement ::= '<' jsxTagName jsxAttributes ( '/>' | '>' jsxChildren '' )` +- `jsxTagName ::= Name { '.' Name }` +- `jsxAttributes ::= { jsxAttribute }` +- `jsxAttribute ::= Name [ '=' ( String | '{' expr '}' ) ]` +- `jsxChildren ::= { jsxChild }` +- `jsxChild ::= jsxElement | '{' expr '}'` + +Notes: + +- `{ expr }` is used for embedding arbitrary Luau expressions in children and attribute values. +- Attribute shorthand (``) is allowed and is equivalent to `disabled={true}`. + +### Desugaring + +JSX constructs are desugared in the parser into an ordinary Luau function call: + +#### Tag lowering + +- If the tag begins with a lowercase identifier and has no dots (e.g. ``), it lowers to a string tag: `"frame"`. +- Otherwise it lowers to an identifier/dotted expression (e.g. `` lowers to `Foo.Bar`). + +#### Props lowering + +Attributes lower into a table literal (record fields). If there are no attributes, props lower to `nil`. + +#### Children lowering + +Children lower into additional positional arguments after `(tag, props)`. + +#### Examples + +Self-closing: + +```lua +return +``` + +Desugars to (conceptually): + +```lua +return jsx(Foo, { bar = x }) +``` + +Lowercase tag: + +```lua +return +``` + +Desugars to: + +```lua +return jsx("frame", nil) +``` + +Nested child: + +```lua +return +``` + +Desugars to: + +```lua +return jsx(Foo, nil, jsx(Bar, nil)) +``` + +Boolean attribute shorthand: + +```lua +return +``` + +Desugars to: + +```lua +return jsx(Foo, { disabled = true }) +``` + +### Runtime / library integration + +Luau does **not** include React/Roact. JSX desugars to a call to a function named `jsx`. Projects can provide this in whichever way fits their runtime: + +- `jsx = React.createElement` +- `jsx = Roact.createElement` +- `jsx = MyCustomElementFactory` + +This keeps the language neutral and allows different runtimes to adopt the syntax without coupling Luau to any one library. + +#### Intrinsics mapping for lowercase tags + +Because lowercase tags lower to string values (e.g. `` → `jsx("frame", ...)`), most runtimes will want a small **lookup table** that maps these “intrinsic” names to the runtime’s native element types (for Roblox, typically Instance class names such as `"Frame"`). + +For example: + +```lua +local Intrinsics = { + frame = "Frame", + textlabel = "TextLabel", + uilistlayout = "UIListLayout", + -- ... (many more) +} + +function jsx(tag, props, ...) + if type(tag) == "string" then + local className = Intrinsics[tag] + assert(className, ("Unknown intrinsic tag %q"):format(tag)) + -- dispatch to your runtime using className + else + -- tag is a component (function/table/etc.) -> dispatch accordingly + end +end +``` + +This approach is explicit and predictable, avoids ambiguous casing rules, and allows aliases (`div = "Frame"`) if desired. + +### Example: binding JSX to Fusion (community framework) + +Fusion is a popular community framework for Roblox that uses a declarative style for Instances and reactive state. Without changing the JSX proposal, a project can adopt JSX by binding `jsx` to a Fusion element factory. + +In practice, Fusion code commonly looks like `Fusion.New("Frame"){ ... }` and uses special keys like `Fusion.Children` for child lists. The exact adapter shape may vary by project, but a representative sketch is: + +```lua +local Fusion = require(Packages.Fusion) +local New = Fusion.New +local Children = Fusion.Children + +local Intrinsics = { + textlabel = "TextLabel", + frame = "Frame", + -- ... +} + +-- One possible adapter shape: +-- jsx("frame", props, ...children) -> Fusion.New("Frame")(props) +-- jsx(FooComponent, props, ...children) -> FooComponent(props, ...children) (project-defined) +function jsx(tag, props, ...) + props = props or {} + props[Children] = { ... } + + if type(tag) == "string" then + return New(Intrinsics[tag])(props) + else + -- component case is project-defined; this is just a sketch + return tag(props) + end +end + +local State = Fusion.Value("Hello") + +local ui = + +``` + +This example illustrates the key point: **JSX is a “call tree” surface syntax**, and Fusion (not Luau) defines the semantics. + +### Example: non-UI usage (scene graph / “fiber-like” tree) + +JSX can also be used for non-UI declarative graphs, e.g. building a 3D scene tree out of Roblox Instances (a “fiber-like” idea: a declarative tree that produces runtime objects). + +```lua +local Intrinsics = { + model = "Model", + part = "Part", + pointlight = "PointLight", + attachment = "Attachment", + -- ... +} + +-- A minimal Instance-producing factory: +function jsx(tag, props, ...) + assert(type(tag) == "string", "This example expects intrinsic (lowercase) tags") + local className = assert(Intrinsics[tag], ("Unknown intrinsic tag %q"):format(tag)) + + local inst = Instance.new(className) + props = props or {} + + -- Apply properties + for k, v in pairs(props) do + inst[k] = v + end + + -- Parent children to this instance + for _, child in ipairs({ ... }) do + if typeof(child) == "Instance" then + child.Parent = inst + end + end + + return inst +end + +local scene = + + + + + + +``` + +This keeps the same core idea: the tag is a string, props are a table, and children are a list. The meaning is defined by the chosen runtime factory (`jsx`), and JSX is simply a concise way to express the tree. + +### Compatibility & ambiguity analysis + +#### Backwards compatibility + +With `.luax` + flag gating, existing `.lua`/`.luau` source is unaffected. + +Within `.luax`, code that uses `<` for comparisons (e.g. `a < b`) remains valid; JSX is only recognized when the parser expects a `simpleexp` and sees `<` as the next token in that position. + +#### Grammar ambiguity concerns + +Key ambiguity sources: + +- `<` as binary operator vs JSX element start +- `>()`) + +Mitigations: + +- Restrict JSX recognition to positions where a `simpleexp` is parsed. +- Keep JSX syntax small initially (no raw text children, fragments, or spreads). +- Continue gating behind a flag to allow collecting real-world compatibility data. + +### Editor integration & tooling considerations + +Adding JSX affects: + +- Tokenization/highlighting for `.luax` +- Parsing for autocomplete, formatter/pretty-printer, and incremental parse +- Error recovery expectations (JSX introduces new “paired delimiter” structures) + +Mitigations: + +- Keep JSX AST lowering to existing nodes so downstream tools operate on familiar AST shapes. +- Ensure error recovery always makes progress to avoid hangs in partial code states. +- Treat `.luax` as a distinct language mode/extension for editors. + +### Security / sandboxing considerations + +The syntax itself is not inherently unsafe, but it makes calling a factory function extremely easy. Sandboxing concerns are the same as for ordinary function calls: + +- The host controls which globals exist (including `jsx`). +- Environments can restrict or replace `jsx` to safe implementations. + +### Performance considerations + +- Parsing adds new branches and lookahead, but is expected to be negligible compared to overall parsing cost. +- Desugaring into existing AST nodes avoids adding new runtime node kinds and keeps later stages (typechecking, codegen) unchanged. + +### Status + +**Implemented (flagged / experimental)** in this repository as a spike: + +- `FFlag::LuauJsx` gating +- `.luax` extension used to enable `ParseOptions::allowJsx` +- Parser desugars JSX to `jsx(tag, props, ...children)` +- Parser tests cover collision edge cases and error recovery + +This RFC is still required before any user-facing rollout. + +## Drawbacks + +- Adds a significant new surface area to the language syntax, which increases learning and maintenance cost. +- JSX has a history of subtle grammar ambiguities; even with careful gating, future syntax additions may conflict. +- Editor/tooling support must be updated (highlighting, formatting, autocomplete), and mismatches can degrade UX. +- The “factory function” convention (`jsx`) is another community contract to standardize (or allow configuring). + +## Alternatives + +1. **Do nothing**: keep using `createElement(...)` calls and helper builders. +2. **Library-level DSL**: implement an embedded DSL using Lua syntax only (functions/tables/metatables). This avoids parser changes but is typically more verbose and harder to read. +3. **Transpile step**: introduce a `.luax -> .luau` transformer. This conflicts with the “interpreted/no build step” constraint and complicates tooling. +4. **New AST node kinds**: keep JSX nodes in the AST and handle them in later passes. This can improve tooling fidelity but increases implementation complexity across compiler/typechecker/codegen and makes sandboxing harder to reason about. +5. **Configurable factory** (future): allow `--!jsxFactory=...` or similar to avoid requiring `jsx` to be global; still requires a syntax proposal, and introduces additional complexity in binding resolution.