Skip to content

Commit

Permalink
Merge pull request #197 from Workiva/rem_sensor_async_mount
Browse files Browse the repository at this point in the history
AF-2994 Rem sensor async mount
  • Loading branch information
aaronlademann-wf authored Oct 30, 2018
2 parents a23f407 + 03841c4 commit a5c3add
Show file tree
Hide file tree
Showing 2 changed files with 189 additions and 76 deletions.
109 changes: 66 additions & 43 deletions lib/src/util/rem_util.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,46 +36,66 @@ var _changeSensor;
@visibleForTesting
dynamic get changeSensor => _changeSensor;

bool _shouldStillMountRemChangeSensor = false;
Element _changeSensorMountNode;
@visibleForTesting
Element get changeSensorMountNode => _changeSensorMountNode;

void _initRemChangeSensor() {
if (_changeSensor != null) return;
// Force lazy-initialization of this variable if it hasn't happened already.
_rootFontSize;

_changeSensorMountNode = new DivElement()
..id = 'rem_change_sensor';

// Ensure the sensor doesn't interfere with the rest of the page.
_changeSensorMountNode.style
..width = '0'
..height = '0'
..overflow = 'hidden'
..position = 'absolute'
..zIndex = '-1';

document.body.append(_changeSensorMountNode);

_changeSensor = react_dom.render((Dom.div()
..style = const {
'position': 'absolute',
'visibility': 'hidden',
// ResizeSensor doesn't pick up sub-pixel changes due to its use of offsetWidth/Height,
// so use 100rem for greater precision.
'width': '100rem',
'height': '100rem',
@visibleForTesting
Future<Null> initRemChangeSensor() {
_shouldStillMountRemChangeSensor = true;

// Mount this asynchronously in case this initialization was triggered by
// a `toRem` call inside a component's `render`.
// (React emits a warning and sometimes gets in a bad state
// when mounting component from inside `render`).
return new Future(() {
// Short-circuit if destroyed during the async gap.
if (!_shouldStillMountRemChangeSensor) {
return;
}

// Short-circuit if already initialized (needs a check after async gap
// to handle race conditions when this was called multiple times).
if (changeSensorMountNode != null) {
return;
}
)(
(ResizeSensor()..onResize = (ResizeSensorEvent e) {
recomputeRootFontSize();
})()
), _changeSensorMountNode);

// Force lazy-initialization of this variable if it hasn't happened already.
_rootFontSize;

_changeSensorMountNode = new DivElement()
..id = 'rem_change_sensor';

// Ensure the sensor doesn't interfere with the rest of the page.
_changeSensorMountNode.style
..width = '0'
..height = '0'
..overflow = 'hidden'
..position = 'absolute'
..zIndex = '-1';

document.body.append(_changeSensorMountNode);

_changeSensor = react_dom.render((Dom.div()
..style = const {
'position': 'absolute',
'visibility': 'hidden',
// ResizeSensor doesn't pick up sub-pixel changes due to its use of offsetWidth/Height,
// so use 100rem for greater precision.
'width': '100rem',
'height': '100rem',
}
)(
(ResizeSensor()..onResize = (ResizeSensorEvent e) {
recomputeRootFontSize();
})()
), _changeSensorMountNode);
});
}

final StreamController<double> _remChange = new StreamController.broadcast(onListen: () {
_initRemChangeSensor();
initRemChangeSensor();
});

/// The latest component root font size (rem) value, in pixels.
Expand All @@ -98,18 +118,21 @@ void recomputeRootFontSize() {
}
}

/// A utility that destroys the [_changeSensor] added to the DOM by [_initRemChangeSensor].
/// A utility that destroys the [_changeSensor] added to the DOM by [initRemChangeSensor].
///
/// Can be used, for example, to clean up the DOM in the `tearDown` of a unit test.
Future<Null> destroyRemChangeSensor() async {
if (_changeSensor == null) return;

_changeSensor = null;

react_dom.unmountComponentAtNode(_changeSensorMountNode);
_changeSensorMountNode?.remove();

_changeSensorMountNode = null;
// TODO make this a void function
Future<Null> destroyRemChangeSensor() {
return new Future.sync(() {
_shouldStillMountRemChangeSensor = false;

if (_changeSensor != null) {
react_dom.unmountComponentAtNode(_changeSensorMountNode);
_changeSensorMountNode.remove();
_changeSensorMountNode = null;
_changeSensor = null;
}
});
}

/// Converts a pixel (`px`) [value] to its `rem` equivalent using the current font size
Expand Down Expand Up @@ -146,7 +169,7 @@ CssValue toRem(dynamic value, {bool treatNumAsRem: false, bool passThroughUnsupp
if (browser.isChrome && !component_base.UiProps.testMode) {
// TODO: Why does Zone.ROOT.run not work in unit tests? Passing in Zone.current from the call to toRem() within the test also does not work.
// Zone.ROOT.run(_initRemChangeSensor);
_initRemChangeSensor();
initRemChangeSensor();
}

if (value == null) return null;
Expand Down
156 changes: 123 additions & 33 deletions test/over_react/util/rem_util_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ import '../../test_util/test_util.dart';
/// Main entry point for rem_util testing
main() {
group('rem_util', () {
// Ensure this suite cleans up any sensor nodes it adds to the body,
// and doesn't pollute other tests.
tearDownAll(() async {
await destroyRemChangeSensor();
});

void setRootFontSize(String value) {
document.documentElement.style.fontSize = value;
expect(document.documentElement.getComputedStyle().fontSize, value);
Expand Down Expand Up @@ -210,6 +216,9 @@ main() {
var calls = <double>[];
var listener = onRemChange.listen(calls.add);

// Wait for the async mounting of the rem change sensor node.
await new Future(() {});

expect(querySelector('#rem_change_sensor'), isNotNull);
expect(calls, isEmpty);

Expand All @@ -223,41 +232,57 @@ main() {
listener.cancel();
});

test('does not dispatch duplicate events when there are multiple listeners', () async {
List<double> calls = [];
group('', () {
setUpAll(() async {
// These tests depend on the sensor being initialized before the test starts.
await initRemChangeSensor();
});

var listener1 = onRemChange.listen((_) {});
var listener2 = onRemChange.listen(calls.add);
test('does not dispatch duplicate events when there are multiple listeners', () async {
List<double> calls = [];

var nextChange = onRemChange.first;
setRootFontSize('17px');
var listener1 = onRemChange.listen((_) {});
var listener2 = onRemChange.listen(calls.add);

await nextChange;
// Wait for the async mounting of the rem change sensor node.
await new Future(() {});

expect(calls, hasLength(1));
var nextChange = onRemChange.first;
setRootFontSize('17px');

listener1.cancel();
listener2.cancel();
});
await nextChange;

test('does not dispatch events when recomputeRootFontSize is called and there is no change', () async {
List<double> calls = [];
var listener = onRemChange.listen(calls.add);
expect(calls, hasLength(1));

recomputeRootFontSize();
listener1.cancel();
listener2.cancel();
});

var nextChange = onRemChange.first;
setRootFontSize('17px');
test('does not dispatch events when recomputeRootFontSize is called and there is no change', () async {
List<double> calls = [];
var listener = onRemChange.listen(calls.add);

await nextChange;
// Wait for the async mounting of the rem change sensor node.
await new Future(() {});

expect(calls, [17]);
recomputeRootFontSize();

listener.cancel();
var nextChange = onRemChange.first;
setRootFontSize('17px');

await nextChange;

expect(calls, [17]);

listener.cancel();
});
});
});

test('rootFontSize returns the latest root font size computed', () async {
// This test depends on the sensor being initialized before the test starts.
await initRemChangeSensor();

setRootFontSize('15px');
await onRemChange.first;

Expand All @@ -270,22 +295,16 @@ main() {
});

group('destroyRemChangeSensor', () {
StreamSubscription<double> listener;

setUpAll(() async {
listener = onRemChange.listen((_) {});
await initRemChangeSensor();
expect(changeSensor, isNotNull, reason: 'test setup sanity check');
expect(changeSensorMountNode, isNotNull, reason: 'test setup sanity check');
expect(document.body.children.single, changeSensorMountNode, reason: 'test setup sanity check');

await destroyRemChangeSensor();
});

tearDownAll(() async {
await listener?.cancel();
});

test('removes the `#rem_change_sensor` element', () {
test('adds the `#rem_change_sensor` element', () {
expect(changeSensorMountNode, isNull);
expect(document.body.children, isEmpty);
});
Expand All @@ -295,28 +314,50 @@ main() {
});
});

group('initRemChangeSensor', () {
setUpAll(() async {
await destroyRemChangeSensor();
expect(changeSensor, isNull, reason: 'test setup sanity check');
expect(changeSensorMountNode, isNull, reason: 'test setup sanity check');
expect(document.body.children, isEmpty, reason: 'test setup sanity check');

await initRemChangeSensor();
});

test('adds the `#rem_change_sensor` element', () {
expect(changeSensorMountNode, isNotNull);
expect(document.body.children, [changeSensorMountNode]);
});

test('initializes `_changeSensor` ', () {
expect(changeSensor, isNotNull);
});
});

group('automatically mounts a "rem change sensor" when `toRem` is called for the first time', () {
setUp(() {
destroyRemChangeSensor();
setUp(() async {
await destroyRemChangeSensor();
// Disabling test mode to ensure that the rem change sensor is created.
// See workaround comment in `toRem`.
disableTestMode();
});

tearDown(() {
destroyRemChangeSensor();
tearDown(() async {
await destroyRemChangeSensor();
// Re-enable test mode
enableTestMode();
});

group('in Google Chrome', () {
setUp(() {
setUp(() async {
expect(querySelector('#rem_change_sensor'), isNull,
reason: '#rem_change_sensor element should not get mounted until `toRem` is first called.');
expect(document.documentElement.getComputedStyle().fontSize, isNot('20px'),
reason: 'The tests in this group will not work if the root font size is already 20px.');

toRem('1rem');
// Wait for the async mounting of the rem change sensor node.
await new Future(() {});
});

test('', () {
Expand All @@ -336,5 +377,54 @@ main() {
expect(querySelector('#rem_change_sensor'), isNull, reason: 'test setup sanity check');
}, testOn: '!chrome && !dartium');
}, testOn: 'browser');

group('interleaved asynchonous intialization/destruction of change sensors works without race conditions:', () {
setUp(() async {
// Ensure we start with no sensor.
await destroyRemChangeSensor();
});

tearDown(() async {
await destroyRemChangeSensor();
});

test('multiple unawaited init calls in a row', () async {
await initRemChangeSensor();
await initRemChangeSensor();
await new Future(() {});

expect(querySelectorAll('#rem_change_sensor'), hasLength(1),
reason: 'inits the sensor properly wihtout creating duplicates');

await destroyRemChangeSensor();
expect(querySelector('#rem_change_sensor'), isNull,
reason: 'can properly destroy the sensor afterwards since it was not left in a bad state');
});

test('destroy after unawaited init', () async {
initRemChangeSensor();
await destroyRemChangeSensor();

expect(querySelector('#rem_change_sensor'), isNull,
reason: 'destroys the sensor (or completes it from being inited)');

await initRemChangeSensor();
expect(querySelector('#rem_change_sensor'), isNotNull,
reason: 'can properly init the sensor afterwards since it was not left in a bad state');
});

test('init after unawaited destroy', () async {
destroyRemChangeSensor();
await initRemChangeSensor();
await new Future(() {});

expect(querySelector('#rem_change_sensor'), isNotNull,
reason: 'inits the sensor, since destruction should be sync');

await destroyRemChangeSensor();
expect(querySelector('#rem_change_sensor'), isNull,
reason: 'can properly destroy the sensor afterwards since it was not left in a bad state');
});
});
});
}

0 comments on commit a5c3add

Please sign in to comment.