Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
64 changes: 49 additions & 15 deletions docs/plans/JSX_IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -569,18 +569,52 @@ void main() {
3. **Interoperable** - Can mix old and new syntax freely
4. **No Breaking Changes** - Pure additive feature

## Open Questions

1. **Operator Choice** - Is `>>` the best operator? Alternatives: `|`, `%`, `&`
2. **Naming** - `$div` vs `Div` vs `div_` for factories
3. **Null Children** - How to handle conditional `null` children in lists?
4. **Keys** - How to specify React keys in the new syntax?
5. **Ref Forwarding** - How to attach refs cleanly?

## Next Steps

1. Create `jsx.dart` with Phase 1 implementation
2. Add tests for operator behavior
3. Document usage patterns
4. Gather feedback on syntax preferences
5. Iterate on API design
## Resolved Decisions

The five original open questions are now settled and implemented in
[`jsx.dart`](../../packages/dart_node_react/lib/src/jsx.dart):

1. **Operator Choice** — `>>` is the child operator. It reads top-to-bottom like
nesting and avoids clashing with common arithmetic/bitwise usage.
2. **Naming** — `$`-prefixed factories (`$div`, `$h1`, `$button`, …). The sigil
keeps the DSL distinct from the existing lowercase `div()`/`h1()` factories so
the two can coexist without import juggling.
3. **Null Children** — `null` is filtered out, so `if (cond) $p >> '...'` inside a
children list "just works" for conditional rendering.
4. **Keys** — every list-style factory takes an optional `key:` parameter
(`$li(key: 'item-1')`), threaded straight into the React props.
5. **Ref Forwarding** — interactive and media factories (`$div`, `$input`,
`$button`, `$textarea`, `$a`, `$select`, `$form`, `$img`, `$video`, `$audio`)
take an optional `ref:` (a `JsRef` from `createRef`). Because React 18 hoists
`ref`/`key` out of `props`, the `>>` operator recomposes elements with
`cloneElement` (not `createElement`), so `ref` and `key` survive when children
are attached.

## Phase Status

- **Phase 1 — Operator DSL:** ✅ shipped (`>>`, `$`-factories, fragments, events,
conditional rendering, keys, refs).
- **Phase 2 — Fluent Builders:** ⏸ deferred. The operator DSL covers the ergonomic
need; revisit only if users ask for a chained-builder style.
- **Phase 3 — Convenience Extensions:** ⏸ deferred (`'text'.el`, `20.px`, etc.).
- **Phase 4 — Code Generation:** ⏸ deferred until adoption warrants typed-prop
generation from React type defs.
- **Phase 5 — Macros:** ⏸ blocked on stable Dart macros.

## TODO

- [x] Phase 1: `El` operator type with `>>` for text / element / list children
- [x] Phase 1: `$`-prefixed element factories (container, heading, semantic,
interactive, form, list, media, table, text, misc)
- [x] Phase 1: fragment support (`$fragment`) and generic `$el(tag)` escape hatch
- [x] Conditional rendering via `null` children filtering
- [x] React `key:` parameter on list factories
- [x] Ref forwarding via `ref:` on interactive factories
- [x] Integration tests in
[`test/jsx_test.dart`](../../packages/dart_node_react/test/jsx_test.dart)
- [x] Remove the duplicate `test/jsx/jsx_dsl_test.dart`
- [x] Export the DSL from `dart_node_react.dart`
- [ ] Phase 2: fluent builders (`Div.className(...).children([...])`) — deferred
- [ ] Phase 3: convenience extensions (`'text'.el`, `20.px`) — deferred
- [ ] Phase 4: code generation for typed props — deferred
- [ ] Phase 5: macro-based `jsx'''...'''` literals — blocked on stable macros
46 changes: 36 additions & 10 deletions packages/dart_node_react/lib/src/jsx.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@
/// $button(onClick: handleClick) >> 'Submit'
/// ```
///
/// ### Refs
///
/// Attach a [JsRef] (from `createRef`) to wire up imperative access:
/// ```dart
/// final inputRef = createRef<InputElement>();
/// $input(ref: inputRef.jsRef, type: 'text')
/// // later: inputRef.current?.focus();
/// ```
///
/// ### Conditional Rendering
/// ```dart
/// $div() >> [
Expand All @@ -63,6 +72,7 @@ import 'dart:js_interop';

import 'package:dart_node_react/src/elements.dart';
import 'package:dart_node_react/src/react.dart';
import 'package:dart_node_react/src/ref.dart';
import 'package:dart_node_react/src/synthetic_event.dart';

// =============================================================================
Expand All @@ -75,17 +85,11 @@ import 'package:dart_node_react/src/synthetic_event.dart';
/// Uses a class instead of extension type to enable runtime type checking.
final class El {
/// Wraps an existing element for operator composition.
El(this._element) : _type = _element.type, _props = _element.props;
El(this._element);

/// The wrapped element.
final ReactElement _element;

/// The element's type for recreation.
final JSAny _type;

/// The element's props for recreation.
final JSObject? _props;

/// Access the underlying element.
ReactElement get element => _element;

Expand All @@ -111,18 +115,18 @@ final class El {
};

ReactElement _withTextChild(String text) =>
ReactElement.fromJS(React.createElement(_type, _props, text.toJS));
ReactElement.fromJS(React.cloneElement(_element, null, text.toJS));

ReactElement _withSingleChild(ReactElement child) =>
ReactElement.fromJS(React.createElement(_type, _props, child));
ReactElement.fromJS(React.cloneElement(_element, null, child));

ReactElement _withChildren(List<Object?> children) {
final normalized = <JSAny>[];
for (final child in children) {
final jsChild = _normalizeChild(child);
if (jsChild != null) normalized.add(jsChild);
}
return createElementWithChildren(_type, _props, normalized);
return cloneElementWithRawChildren(_element, normalized);
}

JSAny? _normalizeChild(Object? child) => switch (child) {
Expand Down Expand Up @@ -160,6 +164,7 @@ Map<String, dynamic> _buildJsxProps({
String? key,
String? className,
String? id,
JsRef? ref,
Map<String, dynamic>? style,
Map<String, dynamic>? spread,
Map<String, dynamic>? props,
Expand Down Expand Up @@ -204,6 +209,7 @@ Map<String, dynamic> _buildJsxProps({
if (key != null) p['key'] = key;
if (className != null) p['className'] = className;
if (id != null) p['id'] = id;
if (ref != null) p['ref'] = ref;
if (style != null) p['style'] = convertStyle(style);
// Mouse events
if (onClick != null) p['onClick'] = onClick;
Expand Down Expand Up @@ -357,6 +363,7 @@ El $div({
String? key,
String? className,
String? id,
JsRef? ref,
Map<String, dynamic>? style,
Map<String, dynamic>? spread,
void Function()? onClick,
Expand All @@ -370,6 +377,7 @@ El $div({
key: key,
className: className,
id: id,
ref: ref,
style: style,
spread: spread,
onClick: onClick,
Expand Down Expand Up @@ -608,6 +616,7 @@ El $button({
String? id,
String? type,
bool? disabled,
JsRef? ref,
Map<String, dynamic>? style,
Map<String, dynamic>? spread,
void Function()? onClick,
Expand All @@ -616,6 +625,7 @@ El $button({
key: key,
className: className,
id: id,
ref: ref,
style: style,
spread: spread,
onClick: onClick,
Expand All @@ -633,6 +643,7 @@ El $a({
String? id,
String? target,
String? rel,
JsRef? ref,
Map<String, dynamic>? style,
Map<String, dynamic>? spread,
void Function()? onClick,
Expand All @@ -641,6 +652,7 @@ El $a({
key: key,
className: className,
id: id,
ref: ref,
style: style,
spread: spread,
onClick: onClick,
Expand All @@ -661,13 +673,15 @@ El $form({
String? id,
String? action,
String? method,
JsRef? ref,
Map<String, dynamic>? style,
Map<String, dynamic>? props,
void Function(SyntheticEvent)? onSubmit,
}) {
final p = _buildJsxProps(
className: className,
id: id,
ref: ref,
style: style,
props: props,
onSubmit: onSubmit,
Expand All @@ -689,6 +703,7 @@ El $input({
bool? disabled,
bool? readOnly,
bool? required,
JsRef? ref,
Map<String, dynamic>? style,
Map<String, dynamic>? spread,
void Function(SyntheticEvent)? onChange,
Expand All @@ -702,6 +717,7 @@ El $input({
key: key,
className: className,
id: id,
ref: ref,
style: style,
spread: spread,
onChange: onChange,
Expand Down Expand Up @@ -733,6 +749,7 @@ El $textarea({
bool? disabled,
bool? readOnly,
bool? required,
JsRef? ref,
Map<String, dynamic>? style,
Map<String, dynamic>? props,
void Function(SyntheticEvent)? onChange,
Expand All @@ -743,6 +760,7 @@ El $textarea({
final p = _buildJsxProps(
className: className,
id: id,
ref: ref,
style: style,
props: props,
onChange: onChange,
Expand Down Expand Up @@ -770,6 +788,7 @@ El $select({
bool? disabled,
bool? multiple,
bool? required,
JsRef? ref,
Map<String, dynamic>? style,
Map<String, dynamic>? props,
void Function(SyntheticEvent)? onChange,
Expand All @@ -779,6 +798,7 @@ El $select({
final p = _buildJsxProps(
className: className,
id: id,
ref: ref,
style: style,
props: props,
onChange: onChange,
Expand Down Expand Up @@ -905,13 +925,15 @@ ImgElement $img({
String? id,
int? width,
int? height,
JsRef? ref,
Map<String, dynamic>? style,
Map<String, dynamic>? props,
void Function()? onClick,
}) {
final p = _buildJsxProps(
className: className,
id: id,
ref: ref,
style: style,
props: props,
onClick: onClick,
Expand All @@ -935,12 +957,14 @@ El $video({
bool? loop,
bool? muted,
String? poster,
JsRef? ref,
Map<String, dynamic>? style,
Map<String, dynamic>? props,
}) {
final p = _buildJsxProps(
className: className,
id: id,
ref: ref,
style: style,
props: props,
);
Expand All @@ -964,12 +988,14 @@ El $audio({
bool? autoplay,
bool? loop,
bool? muted,
JsRef? ref,
Map<String, dynamic>? style,
Map<String, dynamic>? props,
}) {
final p = _buildJsxProps(
className: className,
id: id,
ref: ref,
style: style,
props: props,
);
Expand Down
12 changes: 12 additions & 0 deletions packages/dart_node_react/lib/src/react.dart
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,18 @@ JSObject _createElementApply(JSAny type, JSObject props, JSArray children) {
@JS('Array.prototype.concat.call')
external JSArray _concatArrays(JSArray arr1, JSArray arr2);

/// Clone a React element, replacing its children while preserving the
/// original element's props, `ref`, and `key`.
///
/// React 18 hoists `ref`/`key` out of `props`, so rebuilding an element from
/// its `props` alone loses them. `cloneElement` carries them across, which is
/// what the JSX `>>` operator needs when it attaches children. Delegates to the
/// existing clone-with-children machinery, passing no config so props are kept.
ReactElement cloneElementWithRawChildren(
JSObject element,
List<JSAny> children,
) => ReactElement._(_cloneElementWithChildren(element, null, children.toJS));

/// Create props object from a Map (with function conversion)
JSObject createProps(Map<String, dynamic> props) {
final obj = JSObject();
Expand Down
4 changes: 2 additions & 2 deletions packages/dart_node_react/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,14 @@ packages:
path: "../dart_node_core"
relative: true
source: path
version: "0.11.0-beta"
version: "0.0.0-dev"
dart_node_coverage:
dependency: "direct dev"
description:
path: "../dart_node_coverage"
relative: true
source: path
version: "0.9.0-beta"
version: "0.0.0-dev"
file:
dependency: transitive
description:
Expand Down
Loading
Loading