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

FED-471 Add utilities for jsifying/unsifying context props #785

Merged
merged 4 commits into from
Oct 13, 2022
Merged
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
37 changes: 37 additions & 0 deletions doc/wrapping_js_components.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ This guide will walk you through the process of wrapping a JavaScript React comp
- [Conversion through Getters and Setters](#conversion-through-getters-and-setters)
- [Converting JS Objects](#converting-javascript-object-types)
- [Converting Refs](#converting-refs)
- [Converting Contexts](#converting-contexts)
- [Converting Conflicting Function Props](#converting-conflicting-function-props)
- [Using uiJsComponent](#using-uijscomponent)
- [Testing Your Dart Component](#testing-your-dart-component)
Expand Down Expand Up @@ -362,6 +363,7 @@ Exotic types are those that may need more care to convert. Watch carefully for t
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| Object types (`interface`, anon object types, index signature types) | [MUI Autocomplete's ChipProps][example_ts_interface], [MUI Menu's anchorPosition][example_ts_knowntypes] | See examples and the [Converting Objects section](#converting-javascript-object-types) | [RMUI Autocomplete's ChipProps][example_dart_interface], [RMUI Menu's anchorPosition][example_dart_knowntypes] |
| refs (`React.ForwardedRef`, `React.RefObject`, `React.Ref`) | [MUI TextField inputRef][example_ts_ref] | See example and the [Converting Refs section](#converting-refs) | [RMUI TextField inputRef][example_dart_ref] |
| React Context (`React.Context`) | | See example and the [Converting Contexts section](#converting-contexts) | |

##### Supplemental Explanations

Expand Down Expand Up @@ -536,6 +538,41 @@ The steps are:
- The getter should use `unjsifyRefProp` to convert the JS property to Dart.
- The setter should use `jsifyRefProp` to convert the Dart map to JS.


###### Converting Contexts

If you read [Converting Refs](#converting-refs), this section will be very similar (just with different interop utilities). Say you have props that look like:

```ts
interface ArbitraryComponentProps {
someContext: React.Context<string>;
}
```

React contexts are special JS object types that need to be converted to and from Dart using interop utilities. Converting them looks like:

```dart
@Props(keyNamespace: '')
mixin ArbitraryComponentProps on UiProps {
Context<String> get someContext => unjsifyContextProp(_someContext$rawJs);

set someContext(Context<String> value) => _someContext$rawJs = jsifyContextProp(value);

@Accessor(key: 'someContext')
ReactContext _someContext$rawJs;
}
```

> **For more information** on why getters and setters are used here, see the [Conversion through Getters and Setters section](#conversion-through-getters-and-setters).

The steps are:

1. Create a `ReactContext` property that will represent the raw JS context property for the component. This is necessary because the raw JS is not in a consumable form and should not be accessed directly.
1. Add an `@Accessor` annotation to the property with a key that matches the prop name. This will link the Dart context property to the JS prop.
1. Add context getters and setters named the same as the prop.
- The getter should use `unjsifyContextProp` to convert the JS context object to its Dart equivalent.
- The setter should use `jsifyContextProp` to convert the Dart context object to its JS representation.

###### Converting Conflicting Function Props

Some function props may conflict with props that are built into the UiProps base class, which get mixed in from `UbiquitousDomPropsMixin`. This will happen if:
Expand Down
19 changes: 16 additions & 3 deletions lib/src/util/context.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import 'package:meta/meta.dart';
import 'package:over_react/src/component_declaration/builder_helpers.dart' as builder_helpers;
import 'package:over_react/over_react.dart';
import 'package:react/react_client/component_factory.dart';
import 'package:react/react_client/react_interop.dart';
import 'package:react/react_client/js_backed_map.dart';
import 'package:react/react.dart' as react;
Expand Down Expand Up @@ -47,6 +48,20 @@ class Context<TValue> {
// ignore: avoid_types_as_parameter_names
Context(this.Provider, this.Consumer, this.reactDartContext);

factory Context.fromReactDartContext(react.Context<TValue> reactDartContext) {
ProviderProps<TValue> Provider([Map map]) => (ProviderProps<TValue>(map as JsBackedMap)..componentFactory = reactDartContext.Provider);
ConsumerProps<TValue> Consumer([Map map]) => (ConsumerProps<TValue>(map as JsBackedMap)..componentFactory = reactDartContext.Consumer);
return Context<TValue>(Provider, Consumer, reactDartContext);
}

factory Context.fromJsContext(ReactContext jsContext) {
return Context.fromReactDartContext(react.Context<TValue>(
ReactJsContextComponentFactoryProxy(jsContext.Provider, isProvider: true),
ReactJsContextComponentFactoryProxy(jsContext.Consumer, isConsumer: true),
jsContext,
));
}

/// The react.dart version of this context.
final react.Context<TValue> reactDartContext;

Expand Down Expand Up @@ -237,7 +252,5 @@ class _DO_NOT_USE_OR_YOU_WILL_BE_FIRED {
/// Learn more: <https://reactjs.org/docs/context.html#reactcreatecontext>
Context<TValue> createContext<TValue>([TValue defaultValue, int Function(TValue, TValue) calculateChangedBits]) {
final reactDartContext = react.createContext<TValue>(defaultValue, calculateChangedBits != null ? (dynamic arg1, dynamic arg2) => calculateChangedBits(arg1 as TValue, arg2 as TValue) : null);
Provider([Map map]) => (ProviderProps<TValue>(map as JsBackedMap)..componentFactory = reactDartContext.Provider);
Consumer([Map map]) => (ConsumerProps<TValue>(map as JsBackedMap)..componentFactory = reactDartContext.Consumer);
return Context<TValue>(Provider, Consumer, reactDartContext);
return Context<TValue>.fromReactDartContext(reactDartContext);
}
59 changes: 56 additions & 3 deletions lib/src/util/prop_conversion.dart
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import 'package:js/js.dart';
import 'package:js/js_util.dart';
import 'package:meta/meta.dart';
import 'package:over_react/over_react.dart' show Ref;
import 'package:over_react/over_react.dart' show Context, Ref;
import 'package:over_react/src/util/weak_map.dart';
import 'package:react/react_client/js_backed_map.dart';
import 'package:react/react_client/component_factory.dart';
import 'package:react/react_client/react_interop.dart' show JsRef;
import 'package:react/react_client/react_interop.dart' show JsRef, ReactContext;

// Export JsMap since props that utilize jsifyMapProp/unjsifyMapProp
// via custom getters/setters will need JsMap to avoid implicit cast errors.
export 'package:react/react_client/js_backed_map.dart' show JsMap;

// Export ReactContext since props that utilize jsifyContextProp/unjsifyContextProp
// via custom getters/setters will need ReactContext to avoid implicit cast errors.
export 'package:react/react_client/react_interop.dart' show ReactContext;

/// Returns a JS-deep-converted version of [value] for storage in the props map, or null if [value] is null.
///
/// For use in JS component prop setters where the component expects a JS object, but typing the getter/setter
Expand Down Expand Up @@ -88,7 +92,8 @@ dynamic jsifyRefProp(dynamic value) {
///
/// Should be used alongside [unjsifyRefProp].
///
/// Note that Dart refs currently lose their reified types when jsified/unjsified.
/// Note that Dart refs currently lose their reified types when jsified/unjsified,
/// if they have not been passed into [jsifyRefProp] before.
dynamic unjsifyRefProp(dynamic value,
{@visibleForTesting bool throwOnUnhandled = false}) {
// Case 1: null
Expand All @@ -113,6 +118,41 @@ dynamic unjsifyRefProp(dynamic value,
return value;
}

/// Returns [value] converted to its JS context representation for storage in a props map, or null of the [value] is null.
///
/// For use in JS component prop getters where the component expects a JS context, but accepting Dart contexts
/// is more convenient to the consumer reading/writing the props.
///
/// Should be used alongside [unjsifyContextProp].
ReactContext jsifyContextProp(Context value) {
if (value == null) return null;

// Store the original Dart context so we can retrieve it later in unjsifyContextProp.
// See _dartContextForJsContext comment for more info.
_dartContextForJsContext.set(value.jsThis, value);
return value.jsThis;
}

/// Returns [value] converted back into its Dart Context representation, or null if the [value] is null.
///
/// For use in JS component prop getters where the component expects a JS context, but accepting Dart contexts
/// is more convenient to the consumer reading/writing the props.
///
/// Should be used alongside [unjsifyContextProp].
///
/// Note that Dart contexts currently lose their reified types when jsified/unjsified
/// if they have not been passed into [jsifyContextProp] before.
Context<T> unjsifyContextProp<T>(ReactContext value) {
if (value == null) return null;

// Return the original Dart context is there is one, otherwise return a new context.
// See _dartContextForJsContext comment for more info.
final originalContext = _dartContextForJsContext.get(value);
return originalContext != null
? originalContext as Context<T>
: Context<T>.fromJsContext(value);
}

/// A weak mapping from JsRef objects to the original Dart Refs they back.
///
/// Useful for
Expand All @@ -125,3 +165,16 @@ dynamic unjsifyRefProp(dynamic value,
/// We also have to use a WeakMap instead of a JS property (or an Expando, whose DDC implementation uses JS properties),
/// since those can't be used with sealed JS objects (like React.createRef() objects in development builds, and potentially other cases).
final _dartRefForJsRef = WeakMap();

/// A weak mapping from ReactContext objects to the original Dart Contexts they back.
///
/// Useful for
/// 1. Preserving the reified type of the Dart context when it gets jsified/unjsified
/// 2. Telling whether the context was originally a Dart or JS context
///
/// We're using WeakMap here so that we don't have a global object strongly referencing and thus retaining contexts,
/// and not because strong references from the JS contexts to the Dart contexts would be problematic.
///
/// We also have to use a WeakMap instead of a JS property (or an Expando, whose DDC implementation uses JS properties),
/// since those can't be used with sealed JS objects (which may be the case for context objects).
final _dartContextForJsContext = WeakMap();
137 changes: 136 additions & 1 deletion test/over_react/util/prop_conversion_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import 'dart:html';
import 'dart:js_util';

import 'package:js/js.dart';
import 'package:react/react.dart' as react;
import 'package:over_react/over_react.dart';
import 'package:over_react/components.dart' as components;
import 'package:over_react/src/util/js_component.dart';
import 'package:over_react/src/util/prop_conversion.dart';
import 'package:react/react_client/component_factory.dart';
import 'package:react/react_client/react_interop.dart'
show React, ReactClass, ReactComponent;
show React, ReactClass, ReactComponent, ReactContext;
import 'package:react_testing_library/matchers.dart';
import 'package:react_testing_library/react_testing_library.dart';
import 'package:react_testing_library/user_event.dart';
Expand Down Expand Up @@ -226,6 +227,83 @@ main() {
expect(jsifyAndUnjsify(object), same(object));
});
});

group('jsifyContextProp', () {
test('passes through null', () {
expect(jsifyContextProp(null), null);
});

test('converts Dart context objects', () {
final dartContext = createContext();
expect(jsifyContextProp(dartContext), same(dartContext.jsThis));
});
});

group('unjsifyContextProp', () {
test('passes through null', () {
expect(unjsifyContextProp(null), null);
});

test('converts JS context objects to Dart context objects (dynamic generic type)', () {
final jsContext = react.createContext().jsThis;
expect(unjsifyContextProp<dynamic>(jsContext), isA<Context>());
expect(unjsifyContextProp<dynamic>(jsContext).jsThis, same(jsContext));
});

test('converts JS context objects to Dart context objects (with generic type)', () {
final jsContext = react.createContext().jsThis;
expect(unjsifyContextProp<String>(jsContext), isA<Context<String>>());
expect(unjsifyContextProp<String>(jsContext).jsThis, same(jsContext));
});
});

group(
'unjsifyContextProp(jsifyContextProp(object)) returns a value equivalent to `object` when passed',
() {
Context<T> jsifyAndUnjsify<T>(Context<T> value) =>
unjsifyContextProp(jsifyContextProp(value));

test('null', () {
expect(jsifyAndUnjsify(null), null);
});

test('Dart context object', () {
final context = createContext();
expect(jsifyAndUnjsify(context).jsThis, same(context.jsThis));
});

group('Dart context objects (with same reified generic type)', () {
test('when the type parameter is dynamic', () {
final dartContext = createContext<Element>();
// Specify dynamic here so the static type parameter to this method
// doesn't influence the reified type.
expect(jsifyAndUnjsify<dynamic>(dartContext), same(dartContext));

// These expectations are redundant due to the `same` expectation above,
// but this is really the behavior we want to test.
expect(jsifyAndUnjsify<dynamic>(dartContext),
isA<Context>().havingJsThis(same(dartContext.jsThis)),
reason: 'should be backed by the same JS object');
expect(jsifyAndUnjsify<dynamic>(dartContext), isA<Context<Element>>(),
reason: 'should have the same reified type');
});

test('when the type parameter is specified/inferred', () {
final dartContext = createContext<Element>();
// Specify dynamic here so the static type parameter to this method
// doesn't influence the reified type.
expect(jsifyAndUnjsify(dartContext), same(dartContext));

// These expectations are redundant due to the `same` expectation above,
// but this is really the behavior we want to test.
expect(jsifyAndUnjsify(dartContext),
isA<Context>().havingJsThis(same(dartContext.jsThis)),
reason: 'should be backed by the same JS object');
expect(jsifyAndUnjsify(dartContext), isA<Context<Element>>(),
reason: 'should have the same reified type');
});
});
});
});

group(
Expand Down Expand Up @@ -512,6 +590,52 @@ main() {
});
});
});

group('context props using (un)jsifyContextProp', () {
group('get converted to JS context', () {
group('in the setter, and gets unconverted in getter', () {
// This case is a little redundant with the (un)jsifyContextProp tests above, but include it for completeness.
test('when set to a Context', () {
final context = createContext<String>();

final builder = TestJs()..messageContext = context;

final propKey = TestJs.getPropKey((p) => p.messageContext);
expect(builder, {propKey: isA<ReactContext>()},
reason:
'test setup: should have converted to a JS context for storage in props map');
expect(builder.messageContext, isA<Context>(),
reason:
'should have unconverted JS context to a Dart context in the typed getter');
});

// This case is a little redundant with the (un)jsifyContextProp tests above, but include it for completeness.
test('when null', () {
final builder = TestJs();

expect(builder, {}, reason: 'test setup check');
expect(builder.messageContext, isNull);

builder.messageContext = null;
final propKey = TestJs.getPropKey((p) => p.messageContext);
expect(builder, {propKey: null});
expect(builder.messageContext, isNull);
});
});

test('and can be read properly by the JS component', () {
final context = createContext<String>();
final view =
render((context.Provider()..value = 'test context value')(
(TestJs()..messageContext = context)(),
));
final alert = view.getByRole('alert');
expect(alert, hasTextContent('test context value'),
reason: 'messageContext should have been readable by JS component'
' and used to render the alert');
});
});
});
});

group('works as expected in advanced cases:', () {
Expand Down Expand Up @@ -885,6 +1009,11 @@ extension on TypeMatcher<Ref> {
having((ref) => ref.jsRef, 'jsRef', matcher);
}

extension on TypeMatcher<Context> {
Matcher havingJsThis(dynamic matcher) =>
having((ref) => ref.jsThis, 'jsThis', matcher);
}

extension on TypeMatcher<Object> {
Matcher havingToStringValue(dynamic matcher) =>
having((o) => o.toString(), 'toString() value', matcher);
Expand Down Expand Up @@ -1051,6 +1180,12 @@ mixin TestJsProps on UiProps {
dynamic get inputRef => unjsifyRefProp(_$raw$inputRef);
set inputRef(dynamic value) => _$raw$inputRef = jsifyRefProp(value);

@Accessor(key: 'messageContext')
ReactContext _$raw$messageContext;

Context<String> get messageContext => unjsifyContextProp(_$raw$messageContext);
set messageContext(Context<String> value) => _$raw$messageContext = jsifyContextProp(value);

dynamic /*ElementType*/ component;
dynamic /*ElementType*/ inputComponent;
dynamic /*ElementType*/ buttonComponent;
Expand Down
Loading