From bc2b481590c19fa327d4952511984061a2174ecc Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:15:11 +0530 Subject: [PATCH 01/22] Add type defs for Scope/Scope events --- packages/rad/lib/src/core/common/enums.dart | 39 +++++++++++++++++++ .../src/core/common/objects/scope_event.dart | 21 ++++++++++ packages/rad/lib/src/core/common/types.dart | 4 ++ 3 files changed, 64 insertions(+) create mode 100644 packages/rad/lib/src/core/common/objects/scope_event.dart diff --git a/packages/rad/lib/src/core/common/enums.dart b/packages/rad/lib/src/core/common/enums.dart index 3336e7b1..7a9b39f1 100644 --- a/packages/rad/lib/src/core/common/enums.dart +++ b/packages/rad/lib/src/core/common/enums.dart @@ -642,3 +642,42 @@ enum SchedulerTaskType { enum SchedulerEventType { sendNextTask, } + +/// Scope event type. +/// +@internal +enum ScopeEventType { + /// A event that's fired before building scoped widgets for the first time. + /// + willBuildScope, + + /// A event that's fired after building scoped widgets for the first time. + /// + didBuildScope, + + /// A event that's fired after DOM updates from build phase are flushed to + /// the DOM. + /// + didRenderScope, + + /// A event that's fired before rebuilding scoped widgets. + /// + willRebuildScope, + + /// A event that's fired after rebuilding scoped widgets. + /// + didRebuildScope, + + /// A event that's fired after DOM updates from rebuild phase are flushed to + /// the DOM. + /// + didUpdateScope, + + /// A event that's fired before framework un-mount scope from the DOM. + /// + willUnMountScope, + + /// A event that's fired after scope has been removed from the DOM. + /// + didUnMountScope, +} diff --git a/packages/rad/lib/src/core/common/objects/scope_event.dart b/packages/rad/lib/src/core/common/objects/scope_event.dart new file mode 100644 index 00000000..c15af3bc --- /dev/null +++ b/packages/rad/lib/src/core/common/objects/scope_event.dart @@ -0,0 +1,21 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:meta/meta.dart'; + +import 'package:rad/src/core/common/enums.dart'; + +/// A Scope event. +/// +@internal +class ScopeEvent { + /// Type of scope event. + /// + final ScopeEventType type; + + /// Create scope event. + /// + @internal + const ScopeEvent(this.type); +} diff --git a/packages/rad/lib/src/core/common/types.dart b/packages/rad/lib/src/core/common/types.dart index 89d9e9dd..850ca727 100644 --- a/packages/rad/lib/src/core/common/types.dart +++ b/packages/rad/lib/src/core/common/types.dart @@ -11,6 +11,7 @@ import 'package:rad/src/core/common/abstract/build_context.dart'; import 'package:rad/src/core/common/abstract/render_element.dart'; import 'package:rad/src/core/common/enums.dart'; import 'package:rad/src/core/common/objects/render_event.dart'; +import 'package:rad/src/core/common/objects/scope_event.dart'; import 'package:rad/src/core/services/events/emitted_event.dart'; import 'package:rad/src/core/services/scheduler/abstract.dart'; import 'package:rad/src/widgets/abstract/widget.dart'; @@ -42,6 +43,9 @@ typedef RenderElementVisitor = bool Function(RenderElement renderElement); typedef RenderElementCallback = void Function(RenderElement renderElement); +@internal +typedef ScopeEventCallback = void Function(ScopeEvent event); + @internal typedef SchedulerTaskCallback = void Function(SchedulerTask task); From bf0f80c7ba6e033bf67cdc0d9b9b5a7c6ec55997 Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:15:20 +0530 Subject: [PATCH 02/22] Update public API --- packages/rad/lib/rad.dart | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/rad/lib/rad.dart b/packages/rad/lib/rad.dart index 478b4fe3..9461ebda 100644 --- a/packages/rad/lib/rad.dart +++ b/packages/rad/lib/rad.dart @@ -92,6 +92,24 @@ export 'src/core/common/objects/options/debug_options.dart' show DebugOptions; export 'src/core/common/objects/options/router_options.dart' show RouterOptions; export 'src/core/common/objects/meta_information.dart' show MetaInformation; +/* +|-------------------------------------------------------------------------- +| hooks API +|-------------------------------------------------------------------------- +*/ + +export 'src/core/common/abstract/hook.dart' show Hook; +export 'src/core/interface/hooks/types.dart' show HookScope; +export 'src/core/interface/hooks/types.dart' show HookEvent; +export 'src/core/interface/hooks/types.dart' show HookEventType; +export 'src/core/interface/hooks/types.dart' show HookEventCallback; +export 'src/core/interface/hooks/dispatcher.dart' show useHook, setupHook; + +// framework provided hooks + +export 'src/hooks/use_context.dart' show useContext; +export 'src/hooks/use_navigator.dart' show useNavigator; + /* |-------------------------------------------------------------------------- | widgets From 03691785cede45f320219888716ea83808481059 Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:34:03 +0530 Subject: [PATCH 03/22] Add Scope interface and dispatch unit --- .../src/core/interface/scope/abstract.dart | 29 +++++++++++++++++++ .../src/core/interface/scope/dispatcher.dart | 21 ++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 packages/rad/lib/src/core/interface/scope/abstract.dart create mode 100644 packages/rad/lib/src/core/interface/scope/dispatcher.dart diff --git a/packages/rad/lib/src/core/interface/scope/abstract.dart b/packages/rad/lib/src/core/interface/scope/abstract.dart new file mode 100644 index 00000000..05ba1387 --- /dev/null +++ b/packages/rad/lib/src/core/interface/scope/abstract.dart @@ -0,0 +1,29 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:meta/meta.dart'; + +import 'package:rad/src/core/common/abstract/build_context.dart'; +import 'package:rad/src/core/common/enums.dart'; +import 'package:rad/src/core/common/types.dart'; + +/// A scope interface. +/// +@internal +abstract class Scope { + /// Nearest BuildContext. + /// + BuildContext get context; + + /// Rebuild scope. + /// + void performRebuild(); + + /// Add a scope event listener. + /// + void addScopeEventListener( + ScopeEventType eventType, + ScopeEventCallback listener, + ); +} diff --git a/packages/rad/lib/src/core/interface/scope/dispatcher.dart b/packages/rad/lib/src/core/interface/scope/dispatcher.dart new file mode 100644 index 00000000..c49142c1 --- /dev/null +++ b/packages/rad/lib/src/core/interface/scope/dispatcher.dart @@ -0,0 +1,21 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:meta/meta.dart'; + +import 'package:rad/src/core/interface/scope/abstract.dart'; + +/// Try getting current render scope. +/// +@internal +Scope? getScope() => _currentScope; + +@internal +void setScope(Scope? scope) => _currentScope = scope; + +// ----------------------------------------------------------- + +/// Currently executing render scope. +/// +Scope? _currentScope; From 9f2f9d1efa600a66da238c007094737df9221946 Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:35:07 +0530 Subject: [PATCH 04/22] Add RenderScope(standard implementation for Scope) --- .../rad/lib/src/widgets/render_scope.dart | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 packages/rad/lib/src/widgets/render_scope.dart diff --git a/packages/rad/lib/src/widgets/render_scope.dart b/packages/rad/lib/src/widgets/render_scope.dart new file mode 100644 index 00000000..7c94812c --- /dev/null +++ b/packages/rad/lib/src/widgets/render_scope.dart @@ -0,0 +1,206 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:meta/meta.dart'; + +import 'package:rad/src/core/common/abstract/build_context.dart'; +import 'package:rad/src/core/common/abstract/render_element.dart'; +import 'package:rad/src/core/common/enums.dart'; +import 'package:rad/src/core/common/objects/key.dart'; +import 'package:rad/src/core/common/objects/scope_event.dart'; +import 'package:rad/src/core/common/types.dart'; +import 'package:rad/src/core/interface/scope/abstract.dart'; +import 'package:rad/src/core/interface/scope/dispatcher.dart' as scope_unit; +import 'package:rad/src/core/services/scheduler/tasks/widgets_update_dependent_task.dart'; +import 'package:rad/src/core/services/services.dart'; +import 'package:rad/src/core/services/services_resolver.dart'; +import 'package:rad/src/widgets/abstract/widget.dart'; + +/// A widget for creating render scope. +/// +@immutable +@internal +class RenderScope extends Widget { + /// Widget builder. + /// + final Widget Function() builder; + + /// Create hook scope. + /// + const RenderScope(this.builder, {Key? key}) : super(key: key); + + /* + |-------------------------------------------------------------------------- + | widget internals + |-------------------------------------------------------------------------- + */ + + @nonVirtual + @override + DomTagType? get correspondingTag => null; + + @override + bool shouldUpdateWidget(oldWidget) => true; + + @override + createRenderElement(parent) => RenderScopeRenderElement(this, parent); +} + +/* +|-------------------------------------------------------------------------- +| render element +|-------------------------------------------------------------------------- +*/ + +/// RenderScope render element. +/// +@internal +class RenderScopeRenderElement extends RenderElement + with ServicesResolver + implements Scope { + /// Create render scope element. + /// + RenderScopeRenderElement(super.widget, super.parent); + + @override + BuildContext get context => this; + + /// @nodoc + @override + List get widgetChildren { + if (_isInitialBuild) { + _dispatchEvent(ScopeEventType.willBuildScope); + } else { + _dispatchEvent(ScopeEventType.willRebuildScope); + } + + _isInBuildingPhase = true; + var widgetChildren = [(widget as RenderScope).builder()]; + _isInBuildingPhase = false; + + if (_isInitialBuild) { + _dispatchEvent(ScopeEventType.didBuildScope); + _isInitialBuild = false; + } else { + _dispatchEvent(ScopeEventType.didRebuildScope); + } + + if (_isRebuildRequestPending) { + Future.delayed(Duration.zero, () => performRebuild()); + } + + return widgetChildren; + } + + @override + void addScopeEventListener( + ScopeEventType eventType, + ScopeEventCallback listener, + ) { + var listenersForType = _listeners[eventType]; + + if (null == listenersForType) { + _listeners[eventType] = [listener]; + } else { + listenersForType.add(listener); + } + } + + @nonVirtual + @override + void performRebuild() { + if (_isInBuildingPhase) { + _isRebuildRequestPending = true; + + return; + } + + _services.scheduler.addTask( + WidgetsUpdateDependentTask( + dependentRenderElement: this, + beforeTaskCallback: _setScope, + afterTaskCallback: _setScope, + ), + ); + + _isRebuildRequestPending = false; + } + + /// @nodoc + @protected + @override + void register() { + addRenderEventListeners({ + RenderEventType.didRender: (_) => _dispatchEvent( + ScopeEventType.didRenderScope, + ), + RenderEventType.didUpdate: (_) => _dispatchEvent( + ScopeEventType.didUpdateScope, + ), + RenderEventType.willUnMount: (_) => _dispatchEvent( + ScopeEventType.willUnMountScope, + ), + RenderEventType.didUnMount: (_) => _dispatchEvent( + ScopeEventType.didUnMountScope, + ), + }); + } + + /// @nodoc + @protected + @override + render({required widget}) { + _setScope(); + + return null; + } + + /// @nodoc + @protected + @override + update({required updateType, required oldWidget, required newWidget}) { + _setScope(); + + return null; + } + + // ---------------------------------------------------------------------- + // Internals + // ---------------------------------------------------------------------- + + /// Whether in initial build state. + /// + var _isInitialBuild = true; + + /// Is in building/rebuilding phase. + /// + var _isInBuildingPhase = false; + + /// Whether a rebuild request is pending. + /// + var _isRebuildRequestPending = false; + + /// Render scope event listeners. + /// + final _listeners = >{}; + + /// Services instance. + /// + Services get _services => resolveServices(this); + + void _setScope() => scope_unit.setScope(this); + + void _dispatchEvent(ScopeEventType eventType) { + _setScope(); + + var listenersForType = _listeners[eventType]; + if (null != listenersForType) { + var event = ScopeEvent(eventType); + + for (final listener in listenersForType) { + listener(event); + } + } + } +} From 283ea2bcaa049b9446958386017b04766b03fa65 Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:34:24 +0530 Subject: [PATCH 05/22] Add Hook interface and dispatch unit --- .../lib/src/core/common/abstract/hook.dart | 97 +++++++++++++ .../src/core/interface/hooks/dispatcher.dart | 131 ++++++++++++++++++ .../lib/src/core/interface/hooks/types.dart | 23 +++ 3 files changed, 251 insertions(+) create mode 100644 packages/rad/lib/src/core/common/abstract/hook.dart create mode 100644 packages/rad/lib/src/core/interface/hooks/dispatcher.dart create mode 100644 packages/rad/lib/src/core/interface/hooks/types.dart diff --git a/packages/rad/lib/src/core/common/abstract/hook.dart b/packages/rad/lib/src/core/common/abstract/hook.dart new file mode 100644 index 00000000..fc1cd323 --- /dev/null +++ b/packages/rad/lib/src/core/common/abstract/hook.dart @@ -0,0 +1,97 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:meta/meta.dart'; + +import 'package:rad/src/core/common/abstract/build_context.dart'; +import 'package:rad/src/core/interface/hooks/types.dart'; +import 'package:rad/src/core/interface/scope/abstract.dart'; + +/// Base class for hooks. +/// +abstract class Hook { + /// Get context from associated scope. + /// + @nonVirtual + BuildContext? get context => _scope?.context; + + /// Register hook. + /// + /// It's safe to use [addHookEventListeners] in this method for registering + /// hook event listeners. + /// + void register() {} + + /// Tells framework to rebuild the hook's scope. + /// + /// There are couple of things to note while performing rebuilds: + /// + /// - A call to [performRebuild] will enqueue a render request which + /// framework can decide to process at a later stage so hooks should not + /// depend on the expected results of re-render request. + /// + /// - A call to [performRebuild] before or inside [register] is an error. + /// so hooks should not depend on results of rebuild. + /// + @protected + @nonVirtual + void performRebuild() => _scope!.performRebuild(); + + /// Register hook's scope event listeners. + /// + @protected + @nonVirtual + void addHookEventListeners( + Map listeners, + ) { + assert( + _isInRegisterPhase, + 'Please use addHookEventListeners only once inside register()', + ); + _isInRegisterPhase = false; + + _eventListeners = listeners; + } + + /* + |-------------------------------------------------------------------------- + | framework reserved + |-------------------------------------------------------------------------- + */ + + /// Associated scope. + /// + Scope? _scope; + + /// Whether execution of register is pending. + /// + var _isInRegisterPhase = true; + + /// Scope event listeners. + /// + var _eventListeners = const {}; + + /// @nodoc + @internal + @nonVirtual + Map get frameworkHookEventListeners { + return _eventListeners; + } + + /// @nodoc + @internal + @nonVirtual + void frameworkBindScope(Scope scope) { + assert(null == _scope, 'Scope is already bound'); + _scope = scope; + } + + /// @nodoc + @internal + @nonVirtual + void frameworkInitHook() { + register(); + _isInRegisterPhase = false; + } +} diff --git a/packages/rad/lib/src/core/interface/hooks/dispatcher.dart b/packages/rad/lib/src/core/interface/hooks/dispatcher.dart new file mode 100644 index 00000000..25e2008c --- /dev/null +++ b/packages/rad/lib/src/core/interface/hooks/dispatcher.dart @@ -0,0 +1,131 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:rad/rad.dart'; +import 'package:rad/src/core/common/enums.dart'; +import 'package:rad/src/core/interface/scope/abstract.dart'; +import 'package:rad/src/core/interface/scope/dispatcher.dart' as scope_unit; + +/// Try fetching a registered hook at current index. +/// +Hook? useHook() => _getDispatcher().useHook(); + +/// Create and dispatch a new hook at current index. +/// +Hook setupHook(Hook hook) => _getDispatcher().createHook(hook); + +// ----------------------------------------------------------- + +/// Get dispatcher for current scope. +/// +_Dispatcher _getDispatcher() => _Dispatcher.forScope(); + +// We attach a dispatcher object per scope, just to simplify things a bit. + +/// A hook dispatcher. +/// +class _Dispatcher { + /// Current hook index. + /// + var _hookIndex = -1; + + /// List of associated hooks. + /// + final _hooks = []; + + /// Try fetching a existing hook. + /// + Hook? useHook() { + _hookIndex++; + + if (_hooks.length > _hookIndex) { + var existingHook = _hooks[_hookIndex]; + + return existingHook; + } + + return null; + } + + /// Create a new hook. + /// + Hook createHook(Hook hook) { + _hooks.add(hook); + + hook + ..frameworkBindScope(_scope) + ..frameworkInitHook(); + + var listeners = hook.frameworkHookEventListeners; + + HookEventCallback? willBuildListener; + if (listeners.isNotEmpty) { + willBuildListener = listeners.remove(HookEventType.willBuildScope); + } + + listeners.forEach((eventType, callback) { + _scope.addScopeEventListener(eventType, callback); + }); + + if (null != willBuildListener) { + willBuildListener(const HookEvent(HookEventType.willBuildScope)); + } + + return hook; + } + + /// Current hook scope. + /// + static _Dispatcher? _current; + + /// Registered dispatchers. + /// + static final _dispatchers = {}; + + final Scope _scope; + + _Dispatcher._(this._scope); + + /// Get dispatcher associated with current scope. + /// + factory _Dispatcher.forScope() { + var scope = scope_unit.getScope(); + if (null == scope) { + throw Exception('Please use hooks inside scope.'); + } + + var dispatcherToReturn = _dispatchers[scope]; + + if (null == dispatcherToReturn) { + var newDispatcher = _Dispatcher._(scope); + _dispatchers[scope] = newDispatcher; + + scope.addScopeEventListener( + ScopeEventType.willBuildScope, + newDispatcher._resetHookIndex, + ); + + scope.addScopeEventListener( + ScopeEventType.willRebuildScope, + newDispatcher._resetHookIndex, + ); + + scope.addScopeEventListener(ScopeEventType.didUnMountScope, (_) { + _dispatchers.remove(newDispatcher); + }); + + dispatcherToReturn = newDispatcher; + } + + var currentDispatcher = _current; + if (null != currentDispatcher && currentDispatcher != dispatcherToReturn) { + currentDispatcher._resetHookIndex(null); + _current = dispatcherToReturn; + } + + return dispatcherToReturn; + } + + void _resetHookIndex(_) => _hookIndex = -1; +} diff --git a/packages/rad/lib/src/core/interface/hooks/types.dart b/packages/rad/lib/src/core/interface/hooks/types.dart new file mode 100644 index 00000000..df84c2c5 --- /dev/null +++ b/packages/rad/lib/src/core/interface/hooks/types.dart @@ -0,0 +1,23 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:rad/src/core/common/enums.dart'; +import 'package:rad/src/core/common/objects/scope_event.dart'; +import 'package:rad/src/widgets/render_scope.dart'; + +/// A scope for using hooks. +/// +typedef HookScope = RenderScope; + +/// A hook event. +/// +typedef HookEvent = ScopeEvent; + +/// Type of hook event. +/// +typedef HookEventType = ScopeEventType; + +/// A hook event callback. +/// +typedef HookEventCallback = void Function(HookEvent event); From 07c77937fc328646582d8a30e66a6d073c1fe274 Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:37:24 +0530 Subject: [PATCH 06/22] Add a assert helper to test stack --- packages/rad/test/fixtures/test_stack.dart | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/rad/test/fixtures/test_stack.dart b/packages/rad/test/fixtures/test_stack.dart index 845693b0..d6562a97 100644 --- a/packages/rad/test/fixtures/test_stack.dart +++ b/packages/rad/test/fixtures/test_stack.dart @@ -4,6 +4,8 @@ // ignore_for_file: camel_case_types +import '../test_imports.dart'; + /// Test Stack. /// /// Used by tests for logging 'order in which particular event occurs', @@ -23,4 +25,18 @@ class RT_TestStack { bool canPop() => _entries.isNotEmpty; void clearState() => _entries.clear(); + + /// Assert match stack entries + /// + void assertMatch(List expectedStack, {bool inversed = true}) { + for (final entry in expectedStack) { + if (inversed) { + expect(popFromStart(), entry); + } else { + expect(pop(), entry); + } + } + + expect(canPop(), equals(false)); + } } From c31056b0669e9fe544a66afe2a6264b653fc911e Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:37:43 +0530 Subject: [PATCH 07/22] Add tests for Hook and Scope interface --- packages/rad/test/fixtures/test_hook.dart | 80 +++ packages/rad/test/test_imports.dart | 1 + packages/rad/test/tests/hooks/hook_test.dart | 486 +++++++++++++++++++ 3 files changed, 567 insertions(+) create mode 100644 packages/rad/test/fixtures/test_hook.dart create mode 100644 packages/rad/test/tests/hooks/hook_test.dart diff --git a/packages/rad/test/fixtures/test_hook.dart b/packages/rad/test/fixtures/test_hook.dart new file mode 100644 index 00000000..52b5d201 --- /dev/null +++ b/packages/rad/test/fixtures/test_hook.dart @@ -0,0 +1,80 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: camel_case_types + +import '../test_imports.dart'; + +/// Create a test hook. +/// +RT_TestHook useTestHook({ + VoidCallback? eventWillBuildScope, + VoidCallback? eventDidBuildScope, + VoidCallback? eventWillRebuildScope, + VoidCallback? eventDidRebuildScope, + VoidCallback? eventDidRenderScope, + VoidCallback? eventDidUpdateScope, + VoidCallback? eventWillUnMountScope, + VoidCallback? eventDidUnMountScope, +}) { + var useStateHook = useHook(); + useStateHook ??= setupHook(RT_TestHook( + eventWillBuildScope: eventWillBuildScope, + eventDidBuildScope: eventDidBuildScope, + eventWillRebuildScope: eventWillRebuildScope, + eventDidRebuildScope: eventDidRebuildScope, + eventDidRenderScope: eventDidRenderScope, + eventDidUpdateScope: eventDidUpdateScope, + eventWillUnMountScope: eventWillUnMountScope, + eventDidUnMountScope: eventDidUnMountScope, + )); + + if (useStateHook is! RT_TestHook) { + throw Exception('Please make sure your hooks order is not dynamic.'); + } + + return useStateHook; +} + +/// A test hook that allows hooking into its internals. +/// +class RT_TestHook extends Hook { + VoidCallback? eventWillBuildScope; + VoidCallback? eventDidBuildScope; + VoidCallback? eventWillRebuildScope; + VoidCallback? eventDidRebuildScope; + VoidCallback? eventDidRenderScope; + VoidCallback? eventDidUpdateScope; + VoidCallback? eventWillUnMountScope; + VoidCallback? eventDidUnMountScope; + + RT_TestHook({ + this.eventWillBuildScope, + this.eventDidBuildScope, + this.eventWillRebuildScope, + this.eventDidRebuildScope, + this.eventDidRenderScope, + this.eventDidUpdateScope, + this.eventWillUnMountScope, + this.eventDidUnMountScope, + }); + + @override + void register() { + addHookEventListeners({ + HookEventType.willBuildScope: (_) => eventWillBuildScope?.call(), + HookEventType.didBuildScope: (_) => eventDidBuildScope?.call(), + HookEventType.willRebuildScope: (_) => eventWillRebuildScope?.call(), + HookEventType.didRebuildScope: (_) => eventDidRebuildScope?.call(), + HookEventType.didRenderScope: (_) => eventDidRenderScope?.call(), + HookEventType.didUpdateScope: (_) => eventDidUpdateScope?.call(), + HookEventType.willUnMountScope: (_) => eventWillUnMountScope?.call(), + HookEventType.didUnMountScope: (_) => eventDidUnMountScope?.call(), + }); + } + + /// Dispatch a rebuild request. + /// + void dispatchRebuildRequest() => performRebuild(); +} diff --git a/packages/rad/test/test_imports.dart b/packages/rad/test/test_imports.dart index f595f30b..836f1836 100644 --- a/packages/rad/test/test_imports.dart +++ b/packages/rad/test/test_imports.dart @@ -69,6 +69,7 @@ export 'constants/misc_types.dart'; export 'fixtures/test_app.dart'; export 'fixtures/test_bed.dart'; +export 'fixtures/test_hook.dart'; export 'fixtures/test_stack.dart'; export 'fixtures/test_widget.dart'; export 'fixtures/test_widget_stateful.dart'; diff --git a/packages/rad/test/tests/hooks/hook_test.dart b/packages/rad/test/tests/hooks/hook_test.dart new file mode 100644 index 00000000..37bee92a --- /dev/null +++ b/packages/rad/test/tests/hooks/hook_test.dart @@ -0,0 +1,486 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import '../../test_imports.dart'; + +void main() { + RT_AppRunner? app; + + setUp(() { + app = createTestApp()..start(); + }); + + tearDown(() => app!.stop()); + + group('Hook interface tests', () { + test('should fetch existing hooks on update', () async { + Hook? instance1; + Hook? instance2; + Hook? instance3; + + Hook? instance1Again; + Hook? instance2Again; + Hook? instance3Again; + + await build(app!, [ + () { + instance1 = useTestHook(); + instance2 = useTestHook(); + instance3 = useTestHook(); + }, + ]); + + await update(app!, [ + () { + instance1Again = useTestHook(); + instance2Again = useTestHook(); + instance3Again = useTestHook(); + }, + ]); + + expect(instance1, equals(instance1Again)); + expect(instance2, equals(instance2Again)); + expect(instance3, equals(instance3Again)); + + await update(app!, [ + () { + instance1Again = useTestHook(); + instance2Again = useTestHook(); + instance3Again = useTestHook(); + }, + ]); + + expect(instance1, equals(instance1Again)); + expect(instance2, equals(instance2Again)); + expect(instance3, equals(instance3Again)); + }); + + test('should call in order', () async { + var stack = RT_TestStack(); + + // this test exercises the whole system + // if it fails, check the other ones + + await build(app!, [ + () => useTestHook(eventWillBuildScope: () => stack.push('WBuild')), + () => useTestHook(eventDidBuildScope: () => stack.push('DBuild')), + () => useTestHook(eventDidRenderScope: () => stack.push('DRender')), + () => useTestHook(eventWillRebuildScope: () => stack.push('WRebuild')), + () => useTestHook(eventDidRebuildScope: () => stack.push('DRebuild')), + () => useTestHook(eventDidUpdateScope: () => stack.push('DUpdate')), + () => useTestHook(eventWillUnMountScope: () => stack.push('WUnMount')), + () => useTestHook(eventDidUnMountScope: () => stack.push('DUnMount')), + ]); + + await update(app!, [ + () => useTestHook(eventWillBuildScope: () => stack.push('WBuild')), + () => useTestHook(eventDidBuildScope: () => stack.push('DBuild')), + () => useTestHook(eventDidRenderScope: () => stack.push('DRender')), + () => useTestHook(eventWillRebuildScope: () => stack.push('WRebuild')), + () => useTestHook(eventDidRebuildScope: () => stack.push('DRebuild')), + () => useTestHook(eventDidUpdateScope: () => stack.push('DUpdate')), + () => useTestHook(eventWillUnMountScope: () => stack.push('WUnMount')), + () => useTestHook(eventDidUnMountScope: () => stack.push('DUnMount')), + ]); + + await update(app!, [ + () => useTestHook(eventWillBuildScope: () => stack.push('WBuild')), + () => useTestHook(eventDidBuildScope: () => stack.push('DBuild')), + () => useTestHook(eventDidRenderScope: () => stack.push('DRender')), + () => useTestHook(eventWillRebuildScope: () => stack.push('WRebuild')), + () => useTestHook(eventDidRebuildScope: () => stack.push('DRebuild')), + () => useTestHook(eventDidUpdateScope: () => stack.push('DUpdate')), + () => useTestHook(eventWillUnMountScope: () => stack.push('WUnMount')), + () => useTestHook(eventDidUnMountScope: () => stack.push('DUnMount')), + ]); + + await dispose(app!); + + stack.assertMatch([ + 'WBuild', + 'DBuild', + 'DRender', + + // wont get called during initial builds + + // 'WRebuild', + // 'DRebuild', + // 'DUpdate', + // 'WUnMount', + // 'DUnMount', + + // wont get called during updates + + // 'WBuild', + // 'DBuild', + // 'DRender', + 'WRebuild', + 'DRebuild', + 'DUpdate', + + // second update + + 'WRebuild', + 'DRebuild', + 'DUpdate', + + // dispose + + 'WUnMount', + 'DUnMount', + ]); + }); + + // below tests are redundant but makes it easy to pinpoint what's failing + + test('should call WillBuild once', () async { + var stack = RT_TestStack(); + + await build(app!, [ + () => useTestHook(eventWillBuildScope: () => stack.push('WillBuild')), + ]); + + await update(app!, [ + () => useTestHook(eventWillBuildScope: () => stack.push('WillBuild')), + ]); + + stack.assertMatch(['WillBuild']); + + await build(app!, [ + () => useTestHook(eventWillBuildScope: () => stack.push('WillBuild 1')), + () => useTestHook(eventWillBuildScope: () => stack.push('WillBuild 2')), + () => useTestHook(eventWillBuildScope: () => stack.push('WillBuild 3')), + ]); + + await update(app!, [ + () => useTestHook(eventWillBuildScope: () => stack.push('WillBuild 1')), + () => useTestHook(eventWillBuildScope: () => stack.push('WillBuild 2')), + () => useTestHook(eventWillBuildScope: () => stack.push('WillBuild 3')), + ]); + + stack.assertMatch(['WillBuild 1', 'WillBuild 2', 'WillBuild 3']); + }); + + test('should call DidBuild once', () async { + var stack = RT_TestStack(); + + await build(app!, [ + () => useTestHook(eventDidBuildScope: () => stack.push('DidBuild')), + ]); + + await update(app!, [ + () => useTestHook(eventDidBuildScope: () => stack.push('DidBuild')), + ]); + + stack.assertMatch(['DidBuild']); + + await build(app!, [ + () => useTestHook(eventDidBuildScope: () => stack.push('DidBuild 1')), + () => useTestHook(eventDidBuildScope: () => stack.push('DidBuild 2')), + () => useTestHook(eventDidBuildScope: () => stack.push('DidBuild 3')), + ]); + + await update(app!, [ + () => useTestHook(eventDidBuildScope: () => stack.push('DidBuild 1')), + () => useTestHook(eventDidBuildScope: () => stack.push('DidBuild 2')), + () => useTestHook(eventDidBuildScope: () => stack.push('DidBuild 3')), + ]); + + stack.assertMatch(['DidBuild 1', 'DidBuild 2', 'DidBuild 3']); + }); + + test('should call DidRender once', () async { + var stack = RT_TestStack(); + + await build(app!, [ + () => useTestHook(eventDidRenderScope: () => stack.push('DidRender')), + ]); + + await update(app!, [ + () => useTestHook(eventDidRenderScope: () => stack.push('DidRender')), + ]); + + stack.assertMatch(['DidRender']); + + await build(app!, [ + () => useTestHook(eventDidRenderScope: () => stack.push('DidRender 1')), + () => useTestHook(eventDidRenderScope: () => stack.push('DidRender 2')), + () => useTestHook(eventDidRenderScope: () => stack.push('DidRender 3')), + ]); + + await update(app!, [ + () => useTestHook(eventDidRenderScope: () => stack.push('DidRender 1')), + () => useTestHook(eventDidRenderScope: () => stack.push('DidRender 2')), + () => useTestHook(eventDidRenderScope: () => stack.push('DidRender 3')), + ]); + + stack.assertMatch(['DidRender 1', 'DidRender 2', 'DidRender 3']); + }); + + test('should call WillRebuild on each update', () async { + var stack = RT_TestStack(); + + await build(app!, [ + () => useTestHook(eventWillRebuildScope: () => stack.push('WR')), + ]); + + await update(app!, [ + () => useTestHook(eventWillRebuildScope: () => stack.push('WR')), + ]); + + await update(app!, [ + () => useTestHook(eventWillRebuildScope: () => stack.push('WR')), + ]); + + stack.assertMatch(['WR', 'WR']); + + await build(app!, [ + () => useTestHook(eventWillRebuildScope: () => stack.push('WR 1')), + () => useTestHook(eventWillRebuildScope: () => stack.push('WR 2')), + () => useTestHook(eventWillRebuildScope: () => stack.push('WR 3')), + ]); + + await update(app!, [ + () => useTestHook(eventWillRebuildScope: () => stack.push('WR 1')), + () => useTestHook(eventWillRebuildScope: () => stack.push('WR 2')), + () => useTestHook(eventWillRebuildScope: () => stack.push('WR 3')), + ]); + + await update(app!, [ + () => useTestHook(eventWillRebuildScope: () => stack.push('WR 1')), + () => useTestHook(eventWillRebuildScope: () => stack.push('WR 2')), + () => useTestHook(eventWillRebuildScope: () => stack.push('WR 3')), + ]); + + stack.assertMatch(['WR 1', 'WR 2', 'WR 3', 'WR 1', 'WR 2', 'WR 3']); + }); + + test('should call DidRebuild on each update', () async { + var stack = RT_TestStack(); + + await build(app!, [ + () => useTestHook(eventDidRebuildScope: () => stack.push('DR')), + ]); + + await update(app!, [ + () => useTestHook(eventDidRebuildScope: () => stack.push('DR')), + ]); + + await update(app!, [ + () => useTestHook(eventDidRebuildScope: () => stack.push('DR')), + ]); + + stack.assertMatch(['DR', 'DR']); + + await build(app!, [ + () => useTestHook(eventDidRebuildScope: () => stack.push('DR 1')), + () => useTestHook(eventDidRebuildScope: () => stack.push('DR 2')), + () => useTestHook(eventDidRebuildScope: () => stack.push('DR 3')), + ]); + + await update(app!, [ + () => useTestHook(eventDidRebuildScope: () => stack.push('DR 1')), + () => useTestHook(eventDidRebuildScope: () => stack.push('DR 2')), + () => useTestHook(eventDidRebuildScope: () => stack.push('DR 3')), + ]); + + await update(app!, [ + () => useTestHook(eventDidRebuildScope: () => stack.push('DR 1')), + () => useTestHook(eventDidRebuildScope: () => stack.push('DR 2')), + () => useTestHook(eventDidRebuildScope: () => stack.push('DR 3')), + ]); + + stack.assertMatch(['DR 1', 'DR 2', 'DR 3', 'DR 1', 'DR 2', 'DR 3']); + }); + + test('should call DidUpdate on each update', () async { + var stack = RT_TestStack(); + + await build(app!, [ + () => useTestHook(eventDidUpdateScope: () => stack.push('DU')), + ]); + + await update(app!, [ + () => useTestHook(eventDidUpdateScope: () => stack.push('DU')), + ]); + + await update(app!, [ + () => useTestHook(eventDidUpdateScope: () => stack.push('DU')), + ]); + + stack.assertMatch(['DU', 'DU']); + + await build(app!, [ + () => useTestHook(eventDidUpdateScope: () => stack.push('DU 1')), + () => useTestHook(eventDidUpdateScope: () => stack.push('DU 2')), + () => useTestHook(eventDidUpdateScope: () => stack.push('DU 3')), + ]); + + await update(app!, [ + () => useTestHook(eventDidUpdateScope: () => stack.push('DU 1')), + () => useTestHook(eventDidUpdateScope: () => stack.push('DU 2')), + () => useTestHook(eventDidUpdateScope: () => stack.push('DU 3')), + ]); + + await update(app!, [ + () => useTestHook(eventDidUpdateScope: () => stack.push('DU 1')), + () => useTestHook(eventDidUpdateScope: () => stack.push('DU 2')), + () => useTestHook(eventDidUpdateScope: () => stack.push('DU 3')), + ]); + + stack.assertMatch(['DU 1', 'DU 2', 'DU 3', 'DU 1', 'DU 2', 'DU 3']); + }); + + test('should call WillUnMount once', () async { + var stack = RT_TestStack(); + + await build(app!, [ + () => useTestHook(eventWillUnMountScope: () => stack.push('WU')), + ]); + + await update(app!, [ + () => useTestHook(eventWillUnMountScope: () => stack.push('WU')), + ]); + + await dispose(app!); + + stack.assertMatch(['WU']); + + await build(app!, [ + () => useTestHook(eventWillUnMountScope: () => stack.push('WU 1')), + () => useTestHook(eventWillUnMountScope: () => stack.push('WU 2')), + () => useTestHook(eventWillUnMountScope: () => stack.push('WU 3')), + ]); + + await update(app!, [ + () => useTestHook(eventWillUnMountScope: () => stack.push('WU 1')), + () => useTestHook(eventWillUnMountScope: () => stack.push('WU 2')), + () => useTestHook(eventWillUnMountScope: () => stack.push('WU 3')), + ]); + + await dispose(app!); + + stack.assertMatch(['WU 1', 'WU 2', 'WU 3']); + }); + + test('should call DidUnMount once', () async { + var stack = RT_TestStack(); + + await build(app!, [ + () => useTestHook(eventDidUnMountScope: () => stack.push('DU')), + ]); + + await update(app!, [ + () => useTestHook(eventDidUnMountScope: () => stack.push('DU')), + ]); + + await dispose(app!); + + stack.assertMatch(['DU']); + + await build(app!, [ + () => useTestHook(eventDidUnMountScope: () => stack.push('DU 1')), + () => useTestHook(eventDidUnMountScope: () => stack.push('DU 2')), + () => useTestHook(eventDidUnMountScope: () => stack.push('DU 3')), + ]); + + await update(app!, [ + () => useTestHook(eventDidUnMountScope: () => stack.push('DU 1')), + () => useTestHook(eventDidUnMountScope: () => stack.push('DU 2')), + () => useTestHook(eventDidUnMountScope: () => stack.push('DU 3')), + ]); + + await dispose(app!); + + stack.assertMatch(['DU 1', 'DU 2', 'DU 3']); + }); + }); + + group('Hook misc tests', () { + test('should batch & process rebuild requests', () async { + var stack = RT_TestStack(); + + var isInitialRender = true; + + await build(app!, [ + () { + var hook1 = useTestHook( + eventDidBuildScope: () => stack.push('build 1'), + eventDidRebuildScope: () => stack.push('rebuild 1'), + eventDidRenderScope: () => stack.push('render 1'), + ); + + var hook2 = useTestHook( + eventDidBuildScope: () => stack.push('build 2'), + eventDidRebuildScope: () => stack.push('rebuild 2'), + eventDidRenderScope: () => stack.push('render 2'), + ); + + if (isInitialRender) { + isInitialRender = false; // so that we don't go on forever + + hook1.dispatchRebuildRequest(); + hook1.dispatchRebuildRequest(); + hook1.dispatchRebuildRequest(); + hook2.dispatchRebuildRequest(); + hook2.dispatchRebuildRequest(); + hook2.dispatchRebuildRequest(); + } + }, + ]); + + await Future.delayed(Duration(milliseconds: 100)); + + stack.assertMatch([ + 'build 1', + 'build 2', + 'render 1', + 'render 2', + 'rebuild 1', + 'rebuild 2', + ]); + }); + }); +} + +Future build(RT_AppRunner app, List callbacks) async { + await app.buildChildren( + widgets: [ + HookScope(() { + for (final callback in callbacks) { + callback(); + } + + return Text('hello world'); + }), + ], + parentRenderElement: app.appRenderElement, + ); +} + +Future update(RT_AppRunner app, List callbacks) async { + await app.updateChildren( + widgets: [ + HookScope(() { + for (final callback in callbacks) { + callback(); + } + + return Text('hello world'); + }), + ], + updateType: UpdateType.setState, + parentRenderElement: app.appRenderElement, + ); +} + +Future dispose(RT_AppRunner app) async { + await app.updateChildren( + widgets: [ + Text('hello world'), + ], + updateType: UpdateType.setState, + parentRenderElement: app.appRenderElement, + ); +} From 048b6bcf7af54902182ebfc6a069953ac5c38b03 Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:36:52 +0530 Subject: [PATCH 08/22] Add useContext hook --- packages/rad/lib/src/hooks/use_context.dart | 31 +++++++++ .../test/tests/hooks/use_context_test.dart | 66 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 packages/rad/lib/src/hooks/use_context.dart create mode 100644 packages/rad/test/tests/hooks/use_context_test.dart diff --git a/packages/rad/lib/src/hooks/use_context.dart b/packages/rad/lib/src/hooks/use_context.dart new file mode 100644 index 00000000..4ce18851 --- /dev/null +++ b/packages/rad/lib/src/hooks/use_context.dart @@ -0,0 +1,31 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:meta/meta.dart'; + +import 'package:rad/src/core/common/abstract/build_context.dart'; +import 'package:rad/src/core/common/abstract/hook.dart'; +import 'package:rad/src/core/interface/hooks/dispatcher.dart'; + +/// Returns nearest [BuildContext]. +/// +BuildContext useContext() { + var useContextHook = useHook(); + useContextHook ??= setupHook(UseContextHook()); + + if (useContextHook is! UseContextHook) { + throw Exception( + 'Expecting hook of type: $UseContextHook ' + 'but got: ${useContextHook.runtimeType}. ' + 'Please make sure your hooks call order is not dynamic.', + ); + } + + return useContextHook.context!; +} + +/// A hook for getting nearest context. +/// +@internal +class UseContextHook extends Hook {} diff --git a/packages/rad/test/tests/hooks/use_context_test.dart b/packages/rad/test/tests/hooks/use_context_test.dart new file mode 100644 index 00000000..27ee8a3a --- /dev/null +++ b/packages/rad/test/tests/hooks/use_context_test.dart @@ -0,0 +1,66 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import '../../test_imports.dart'; + +void main() { + RT_AppRunner? app; + + setUp(() { + app = createTestApp()..start(); + }); + + tearDown(() => app!.stop()); + + test('should return context of scope', () async { + BuildContext? context; + + await app!.buildChildren( + widgets: [ + HookScope( + () { + context = useContext(); + + return Text('hello world'); + }, + key: Key('scope'), + ), + ], + parentRenderElement: app!.appRenderElement, + ); + + var renderElement = app!.renderElementByKeyValue('scope') as BuildContext; + expect(renderElement, equals(context)); + }); + + test('should return context of nearest scope', () async { + BuildContext? parent; + BuildContext? child; + + await app!.buildChildren( + widgets: [ + HookScope( + () { + parent = useContext(); + + return HookScope( + () { + child = useContext(); + return Text('hello world'); + }, + key: Key('child'), + ); + }, + key: Key('parent'), + ), + ], + parentRenderElement: app!.appRenderElement, + ); + + var childContext = app!.renderElementByKeyValue('child') as BuildContext; + var parentContext = app!.renderElementByKeyValue('parent') as BuildContext; + expect(childContext, equals(child)); + expect(parentContext, equals(parent)); + }); +} From 88062acf8353b8f5b822469c5036798f0a8b3ddd Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:37:02 +0530 Subject: [PATCH 09/22] Add useNavigator hook --- packages/rad/lib/src/hooks/use_navigator.dart | 51 ++++++++++++ .../test/tests/hooks/use_navigator_test.dart | 82 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 packages/rad/lib/src/hooks/use_navigator.dart create mode 100644 packages/rad/test/tests/hooks/use_navigator_test.dart diff --git a/packages/rad/lib/src/hooks/use_navigator.dart b/packages/rad/lib/src/hooks/use_navigator.dart new file mode 100644 index 00000000..3e3e9bc0 --- /dev/null +++ b/packages/rad/lib/src/hooks/use_navigator.dart @@ -0,0 +1,51 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:meta/meta.dart'; + +import 'package:rad/src/core/common/abstract/hook.dart'; +import 'package:rad/src/core/common/objects/key.dart'; +import 'package:rad/src/core/interface/hooks/dispatcher.dart'; +import 'package:rad/src/widgets/navigator.dart'; + +/// Returns nearest navigator state. +/// +/// If provided [byKey], will fetch state of a navigator with matching key. +/// +NavigatorState useNavigator({Key? byKey}) { + var useNavigatorHook = useHook(); + useNavigatorHook ??= setupHook(UseNavigatorHook(byKey)); + + if (useNavigatorHook is! UseNavigatorHook) { + throw Exception( + 'Expecting hook of type: $UseNavigatorHook ' + 'but got: ${useNavigatorHook.runtimeType}. ' + 'Please make sure your hooks call order is not dynamic.', + ); + } + + return useNavigatorHook.state!; +} + +/// A hook for getting navigator state. +/// +@internal +class UseNavigatorHook extends Hook { + /// Match with key(if provided). + /// + final Key? byKey; + + /// Fetched Navigator state. + /// + NavigatorState? state; + + /// Create navigator hook. + /// + UseNavigatorHook(this.byKey); + + @override + void register() { + state = Navigator.of(context!, byKey: byKey); + } +} diff --git a/packages/rad/test/tests/hooks/use_navigator_test.dart b/packages/rad/test/tests/hooks/use_navigator_test.dart new file mode 100644 index 00000000..0978deab --- /dev/null +++ b/packages/rad/test/tests/hooks/use_navigator_test.dart @@ -0,0 +1,82 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import '../../test_imports.dart'; + +void main() { + RT_AppRunner? app; + + setUp(() { + app = createTestApp()..start(); + }); + + tearDown(() => app!.stop()); + + test('should return state of enclosing navigator', () async { + NavigatorState? state; + + await app!.buildChildren( + widgets: [ + Navigator(key: Key('navigator'), routes: [ + Route( + name: 'some-route', + page: HookScope( + () { + state = useNavigator(); + return Text('hello world'); + }, + key: Key('scope'), + ), + ), + ]), + ], + parentRenderElement: app!.appRenderElement, + ); + + var renderElement = app!.renderElementByKeyValue('navigator'); + renderElement as NavigatorRenderElement; + expect(renderElement.state, equals(state)); + }); + + test('should return state of correct navigator', () async { + NavigatorState? parent; + NavigatorState? child; + + await app!.buildChildren( + widgets: [ + Navigator(key: Key('parent'), routes: [ + Route( + name: 'main', + page: Navigator( + key: Key('child'), + routes: [ + Route( + name: 'some-route', + page: HookScope( + () { + child = useNavigator(); + parent = useNavigator(byKey: Key('parent')); + + return Text('hello world'); + }, + key: Key('scope'), + ), + ), + ], + ), + ), + ]) + ], + parentRenderElement: app!.appRenderElement, + ); + + var parentElement = app!.renderElementByKeyValue('parent'); + parentElement as NavigatorRenderElement; + var childElement = app!.renderElementByKeyValue('child'); + childElement as NavigatorRenderElement; + + expect(parentElement.state, equals(parent)); + expect(childElement.state, equals(child)); + }); +} From cb0b0209ecfafb957eb8bfa3f54919e050ac6fc2 Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:40:51 +0530 Subject: [PATCH 10/22] Add assets for hooks package --- packages/rad_hooks/CHANGELOG.md | 3 + packages/rad_hooks/LICENSE | 25 ++ packages/rad_hooks/analysis_options.yaml | 13 + packages/rad_hooks/pubspec.lock | 348 +++++++++++++++++++++++ packages/rad_hooks/pubspec.yaml | 35 +++ 5 files changed, 424 insertions(+) create mode 100644 packages/rad_hooks/CHANGELOG.md create mode 100644 packages/rad_hooks/LICENSE create mode 100644 packages/rad_hooks/analysis_options.yaml create mode 100644 packages/rad_hooks/pubspec.lock create mode 100644 packages/rad_hooks/pubspec.yaml diff --git a/packages/rad_hooks/CHANGELOG.md b/packages/rad_hooks/CHANGELOG.md new file mode 100644 index 00000000..951a5a3f --- /dev/null +++ b/packages/rad_hooks/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +- Initial release. diff --git a/packages/rad_hooks/LICENSE b/packages/rad_hooks/LICENSE new file mode 100644 index 00000000..063dca12 --- /dev/null +++ b/packages/rad_hooks/LICENSE @@ -0,0 +1,25 @@ +Copyright 2022 erlage. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of the author nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/rad_hooks/analysis_options.yaml b/packages/rad_hooks/analysis_options.yaml new file mode 100644 index 00000000..9378bc6e --- /dev/null +++ b/packages/rad_hooks/analysis_options.yaml @@ -0,0 +1,13 @@ +include: ../../lints/rad.yaml + +analyzer: + language: + strict-inference: true + strict-raw-types: true + + exclude: + - example/** + +linter: + rules: + directives_ordering: false diff --git a/packages/rad_hooks/pubspec.lock b/packages/rad_hooks/pubspec.lock new file mode 100644 index 00000000..c421f45f --- /dev/null +++ b/packages/rad_hooks/pubspec.lock @@ -0,0 +1,348 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + url: "https://pub.dartlang.org" + source: hosted + version: "43.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + url: "https://pub.dartlang.org" + source: hosted + version: "4.3.1" + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.1" + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.9.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.16.0" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" + coverage: + dependency: transitive + description: + name: coverage + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.0" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.2" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" + glob: + dependency: transitive + description: + name: glob + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.1" + import_sorter: + dependency: "direct dev" + description: + name: import_sorter + url: "https://pub.dartlang.org" + source: hosted + version: "4.6.0" + io: + dependency: transitive + description: + name: io + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.4" + logging: + dependency: transitive + description: + name: logging + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.12" + meta: + dependency: "direct main" + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + mime: + dependency: transitive + description: + name: mime + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + node_preamble: + dependency: transitive + description: + name: node_preamble + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + package_config: + dependency: transitive + description: + name: package_config + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.2" + pool: + dependency: transitive + description: + name: pool + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + rad: + dependency: "direct main" + description: + path: "../rad" + relative: true + source: path + version: "1.2.0" + rad_test: + dependency: "direct dev" + description: + name: rad_test + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.0" + shelf: + dependency: transitive + description: + name: shelf + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + shelf_static: + dependency: transitive + description: + name: shelf_static + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + source_maps: + dependency: transitive + description: + name: source_maps + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.10" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + test: + dependency: "direct main" + description: + name: test + url: "https://pub.dartlang.org" + source: hosted + version: "1.21.4" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.12" + test_core: + dependency: transitive + description: + name: test_core + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.16" + tint: + dependency: transitive + description: + name: tint + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + vm_service: + dependency: transitive + description: + name: vm_service + url: "https://pub.dartlang.org" + source: hosted + version: "9.3.0" + watcher: + dependency: transitive + description: + name: watcher + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.1" +sdks: + dart: ">=2.17.0 <3.0.0" diff --git a/packages/rad_hooks/pubspec.yaml b/packages/rad_hooks/pubspec.yaml new file mode 100644 index 00000000..1ddba3c1 --- /dev/null +++ b/packages/rad_hooks/pubspec.yaml @@ -0,0 +1,35 @@ +name: rad_hooks +version: 0.1.0 +publish_to: none +description: A set of commonly used hooks for using in your Rad applications. + +homepage: https://github.com/erlage/rad +documentation: https://github.com/erlage/rad#readme +repository: https://github.com/erlage/rad +issue_tracker: https://github.com/erlage/rad/issues + +environment: + sdk: '>=2.17.0 <3.0.0' + +platforms: + web: + +dependencies: + rad: '>=1.2.0 <2.0.0' + + meta: ^1.8.0 + test: ^1.21.1 + +dev_dependencies: + rad_test: ^0.6.0 + import_sorter: ^4.6.0 + +import_sorter: + emojis: false + comments: false + ignored_files: + - \/test\/* + +dependency_overrides: + rad: + path: ../../packages/rad From d620c98d4abb04b2e2b57ae1ff39c6729af05447 Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:42:12 +0530 Subject: [PATCH 11/22] Add useState hook --- packages/rad_hooks/lib/rad_hooks.dart | 11 ++++++ packages/rad_hooks/lib/src/use_state.dart | 45 +++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 packages/rad_hooks/lib/rad_hooks.dart create mode 100644 packages/rad_hooks/lib/src/use_state.dart diff --git a/packages/rad_hooks/lib/rad_hooks.dart b/packages/rad_hooks/lib/rad_hooks.dart new file mode 100644 index 00000000..369b9435 --- /dev/null +++ b/packages/rad_hooks/lib/rad_hooks.dart @@ -0,0 +1,11 @@ +/// A set of commonly used Hooks for using in your Rad applications. +/// +library rad_hooks; + +/* +|-------------------------------------------------------------------------- +| hooks +|-------------------------------------------------------------------------- +*/ + +export 'src/use_state.dart' show useState, UseStateHook; diff --git a/packages/rad_hooks/lib/src/use_state.dart b/packages/rad_hooks/lib/src/use_state.dart new file mode 100644 index 00000000..53a8cd0c --- /dev/null +++ b/packages/rad_hooks/lib/src/use_state.dart @@ -0,0 +1,45 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:rad/rad.dart'; + +/// Returns a state object whose .value property is initialized to the +/// passed argument [initialState]. +/// +/// During subsequent re-renders, the .value property of object will +/// always be the most recent value after applying updates. +/// +UseStateHook useState(T initialState) { + var useStateHook = useHook(); + useStateHook ??= setupHook(UseStateHook._(initialState)); + + if (useStateHook is! UseStateHook) { + throw Exception( + 'Expecting hook of type: $UseStateHook ' + 'but got: ${useStateHook.runtimeType}. ' + 'Please make sure your hooks call order is not dynamic.', + ); + } + + return useStateHook; +} + +/// A hook for creating stateful value. +/// +/// Changing value of this hook will enqueues a re-render of the scope. +/// +class UseStateHook extends Hook { + /// Current value. + /// + T get value => _value; + T _value; + + set value(T value) { + _value = value; + + performRebuild(); + } + + UseStateHook._(this._value); +} From 870583345d245e0af14f52491c6845cf97af6c2d Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:42:43 +0530 Subject: [PATCH 12/22] Add useRef hook --- packages/rad_hooks/lib/rad_hooks.dart | 1 + packages/rad_hooks/lib/src/use_ref.dart | 40 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 packages/rad_hooks/lib/src/use_ref.dart diff --git a/packages/rad_hooks/lib/rad_hooks.dart b/packages/rad_hooks/lib/rad_hooks.dart index 369b9435..36515bbd 100644 --- a/packages/rad_hooks/lib/rad_hooks.dart +++ b/packages/rad_hooks/lib/rad_hooks.dart @@ -9,3 +9,4 @@ library rad_hooks; */ export 'src/use_state.dart' show useState, UseStateHook; +export 'src/use_ref.dart' show useRef, UseRefHook; diff --git a/packages/rad_hooks/lib/src/use_ref.dart b/packages/rad_hooks/lib/src/use_ref.dart new file mode 100644 index 00000000..dd8f99ff --- /dev/null +++ b/packages/rad_hooks/lib/src/use_ref.dart @@ -0,0 +1,40 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:rad/rad.dart'; + +import 'package:rad_hooks/src/use_state.dart'; + +/// [useRef] returns a mutable ref object whose .value property is +/// initialized to the passed argument [initialValue]. +/// +/// The returned object will persist for the full lifetime of the scope. +/// +UseRefHook useRef(T initialValue) { + var useRefHook = useHook(); + useRefHook ??= setupHook(UseRefHook._(initialValue)); + + if (useRefHook is! UseRefHook) { + throw Exception( + 'Expecting hook of type: $UseRefHook ' + 'but got: ${useRefHook.runtimeType}. ' + 'Please make sure your hooks call order is not dynamic.', + ); + } + + return useRefHook; +} + +/// A hook for creating stateless value. +/// +/// Unlike value of a [useState] hook, a change in value of [useRef] won't cause +/// scope re-render. +/// +class UseRefHook extends Hook { + /// Current value. + /// + T value; + + UseRefHook._(this.value); +} From 1dd142309b073830eafd5e111bc21de0d49d7cff Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:43:08 +0530 Subject: [PATCH 13/22] Add useEffect hook --- packages/rad_hooks/lib/rad_hooks.dart | 1 + packages/rad_hooks/lib/src/abstract.dart | 68 +++++++++++++ packages/rad_hooks/lib/src/use_effect.dart | 109 +++++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 packages/rad_hooks/lib/src/abstract.dart create mode 100644 packages/rad_hooks/lib/src/use_effect.dart diff --git a/packages/rad_hooks/lib/rad_hooks.dart b/packages/rad_hooks/lib/rad_hooks.dart index 36515bbd..21b6f95e 100644 --- a/packages/rad_hooks/lib/rad_hooks.dart +++ b/packages/rad_hooks/lib/rad_hooks.dart @@ -10,3 +10,4 @@ library rad_hooks; export 'src/use_state.dart' show useState, UseStateHook; export 'src/use_ref.dart' show useRef, UseRefHook; +export 'src/use_effect.dart' show useEffect; diff --git a/packages/rad_hooks/lib/src/abstract.dart b/packages/rad_hooks/lib/src/abstract.dart new file mode 100644 index 00000000..767366ee --- /dev/null +++ b/packages/rad_hooks/lib/src/abstract.dart @@ -0,0 +1,68 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:meta/meta.dart'; +import 'package:rad/rad.dart'; + +/// A simple base for dependency driven hooks. +/// +@internal +abstract class DependenciesDrivenHook extends Hook { + /// List of current dependencies. + /// + List? _currentDependencies; + + /// Whether dependencies have changed during renders + /// + @nonVirtual + @protected + bool get areDependenciesChanged => _areDependenciesChanged; + var _areDependenciesChanged = false; + + /// Update dependencies. + /// + /// @nodoc + @nonVirtual + void updateDependencies(List? dependencies) { + _areDependenciesChanged = _isDifferent( + dependenciesSetOne: dependencies, + dependenciesSetTwo: _currentDependencies, + ); + + _currentDependencies = dependencies; + } + + /// Whether two dependency sets are different. + /// + /// Please note, if one of the set is null or both sets are null we consider + /// them different. + /// + bool _isDifferent({ + required List? dependenciesSetOne, + required List? dependenciesSetTwo, + }) { + if (null == dependenciesSetOne || null == dependenciesSetTwo) { + return true; + } + + if (dependenciesSetOne == dependenciesSetTwo) { + return false; + } + + if (dependenciesSetOne.length != dependenciesSetTwo.length) { + return true; + } + + var index = -1; + for (final item in dependenciesSetOne) { + index++; + + if (item != dependenciesSetTwo[index]) { + return true; + } + } + + return false; + } +} diff --git a/packages/rad_hooks/lib/src/use_effect.dart b/packages/rad_hooks/lib/src/use_effect.dart new file mode 100644 index 00000000..345f881b --- /dev/null +++ b/packages/rad_hooks/lib/src/use_effect.dart @@ -0,0 +1,109 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:meta/meta.dart'; +import 'package:rad/rad.dart'; + +import 'package:rad_hooks/src/abstract.dart'; + +typedef NullableVoidCallback = VoidCallback? Function(); + +/// Accepts a function that contains imperative, possibly effect-full code. +/// +/// Mutations, subscriptions, timers, logging, and other side effects are not +/// allowed inside the main body of a function widget (referred to as Rad's +/// render phase). Doing so will lead to confusing bugs and inconsistencies in +/// the UI. +/// +/// By default, effects run after every completed render, but you can choose to +/// fire them only when certain values have changed. +/// +/// ### Cleaning up an effect +/// +/// Often, effects create resources that need to be cleaned up before the scope +/// leaves the screen, such as a subscription or timer ID. To do this, the +/// function passed to [useEffect] can return a clean-up function. +/// +/// The clean-up function runs after the scope is removed from the UI to +/// prevent memory leaks. Additionally, if scope renders multiple times +/// (as they typically do), the previous effect is cleaned up before executing +/// the next effect. +/// +void useEffect( + NullableVoidCallback callback, [ + List? dependencies, +]) { + var useEffectHook = useHook(); + useEffectHook ??= setupHook(UseEffectHook()); + + if (useEffectHook is! UseEffectHook) { + throw Exception( + 'Expecting hook of type: $UseEffectHook ' + 'but got: ${useEffectHook.runtimeType}. ' + 'Please make sure your hooks call order is not dynamic.', + ); + } + + useEffectHook + ..updateDependencies(dependencies) + ..updateEffectCallback(callback); +} + +/// A hook for doing side effects. +/// +@internal +class UseEffectHook extends DependenciesDrivenHook { + @nonVirtual + @protected + VoidCallback? _cleanUpCallback; + + @nonVirtual + @protected + NullableVoidCallback? _effectCallback; + + @override + void register() { + addHookEventListeners({ + HookEventType.didRenderScope: runHookTasks, + HookEventType.didUpdateScope: runHookTasks, + HookEventType.didUnMountScope: runHookTasks, + }); + } + + @nonVirtual + @protected + void runHookTasks(HookEvent event) { + if (HookEventType.didUnMountScope == event.type) { + runHookCleanUpTasks(); + + return; + } + + if (super.areDependenciesChanged) { + runHookCleanUpTasks(); + runHookEffectTasks(); + } + } + + @nonVirtual + @protected + void updateEffectCallback(NullableVoidCallback effectCallback) { + _effectCallback = effectCallback; + } + + @nonVirtual + @protected + void runHookEffectTasks() { + _cleanUpCallback = _effectCallback!(); + } + + @nonVirtual + @protected + void runHookCleanUpTasks() { + var cleanUpCallback = _cleanUpCallback; + if (null != cleanUpCallback) { + cleanUpCallback(); + } + } +} From 91c57f05b81b3853f2f15c99d998f3cb6d6eb391 Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:44:29 +0530 Subject: [PATCH 14/22] Add useLayoutEffect hook --- packages/rad_hooks/lib/rad_hooks.dart | 1 + .../rad_hooks/lib/src/use_layout_effect.dart | 92 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 packages/rad_hooks/lib/src/use_layout_effect.dart diff --git a/packages/rad_hooks/lib/rad_hooks.dart b/packages/rad_hooks/lib/rad_hooks.dart index 21b6f95e..32c8aed4 100644 --- a/packages/rad_hooks/lib/rad_hooks.dart +++ b/packages/rad_hooks/lib/rad_hooks.dart @@ -11,3 +11,4 @@ library rad_hooks; export 'src/use_state.dart' show useState, UseStateHook; export 'src/use_ref.dart' show useRef, UseRefHook; export 'src/use_effect.dart' show useEffect; +export 'src/use_layout_effect.dart' show useLayoutEffect; diff --git a/packages/rad_hooks/lib/src/use_layout_effect.dart b/packages/rad_hooks/lib/src/use_layout_effect.dart new file mode 100644 index 00000000..9d91f76a --- /dev/null +++ b/packages/rad_hooks/lib/src/use_layout_effect.dart @@ -0,0 +1,92 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:meta/meta.dart'; +import 'package:rad/rad.dart'; + +import 'package:rad_hooks/src/abstract.dart'; + +typedef NullableVoidCallback = VoidCallback? Function(); + +/// The signature is identical to useEffect, but it fires before DOM updates +/// are flushed to the DOM. At this point, DOM is still in previous state +/// (possibly stale). +/// +void useLayoutEffect( + NullableVoidCallback callback, [ + List? dependencies, +]) { + var useLayoutEffectHook = useHook(); + useLayoutEffectHook ??= setupHook(UseLayoutEffectHook()); + + if (useLayoutEffectHook is! UseLayoutEffectHook) { + throw Exception( + 'Expecting hook of type: $UseLayoutEffectHook ' + 'but got: ${useLayoutEffectHook.runtimeType}. ' + 'Please make sure your hooks call order is not dynamic.', + ); + } + + useLayoutEffectHook + ..updateDependencies(dependencies) + ..updateEffectCallback(callback); +} + +/// A hook for doing side effects. +/// +@internal +class UseLayoutEffectHook extends DependenciesDrivenHook { + @nonVirtual + @protected + VoidCallback? _cleanUpCallback; + + @nonVirtual + @protected + NullableVoidCallback? _effectCallback; + + @override + void register() { + addHookEventListeners({ + HookEventType.didBuildScope: runHookTasks, + HookEventType.didRebuildScope: runHookTasks, + HookEventType.willUnMountScope: runHookTasks, + }); + } + + @nonVirtual + @protected + void runHookTasks(HookEvent event) { + if (HookEventType.willUnMountScope == event.type) { + runHookCleanUpTasks(); + + return; + } + + if (super.areDependenciesChanged) { + runHookCleanUpTasks(); + runHookEffectTasks(); + } + } + + @nonVirtual + @protected + void updateEffectCallback(NullableVoidCallback effectCallback) { + _effectCallback = effectCallback; + } + + @nonVirtual + @protected + void runHookEffectTasks() { + _cleanUpCallback = _effectCallback!(); + } + + @nonVirtual + @protected + void runHookCleanUpTasks() { + var cleanUpCallback = _cleanUpCallback; + if (null != cleanUpCallback) { + cleanUpCallback(); + } + } +} From 27c10e9cde0f6467cec98318c0474e006732bd49 Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:44:49 +0530 Subject: [PATCH 15/22] Add useMemo hook --- packages/rad_hooks/lib/rad_hooks.dart | 1 + packages/rad_hooks/lib/src/use_memo.dart | 61 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 packages/rad_hooks/lib/src/use_memo.dart diff --git a/packages/rad_hooks/lib/rad_hooks.dart b/packages/rad_hooks/lib/rad_hooks.dart index 32c8aed4..9aeec9f6 100644 --- a/packages/rad_hooks/lib/rad_hooks.dart +++ b/packages/rad_hooks/lib/rad_hooks.dart @@ -12,3 +12,4 @@ export 'src/use_state.dart' show useState, UseStateHook; export 'src/use_ref.dart' show useRef, UseRefHook; export 'src/use_effect.dart' show useEffect; export 'src/use_layout_effect.dart' show useLayoutEffect; +export 'src/use_memo.dart' show useMemo; diff --git a/packages/rad_hooks/lib/src/use_memo.dart b/packages/rad_hooks/lib/src/use_memo.dart new file mode 100644 index 00000000..bfb1224f --- /dev/null +++ b/packages/rad_hooks/lib/src/use_memo.dart @@ -0,0 +1,61 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:meta/meta.dart'; +import 'package:rad/rad.dart'; + +import 'package:rad_hooks/src/abstract.dart'; +import 'package:rad_hooks/src/use_layout_effect.dart'; + +/// Returns a memoized value. +/// +/// Pass a [create] function and an array of [dependencies]. [useMemo] will +/// only recompute the memoized value when one of the dependencies has changed. +/// This optimization helps to avoid expensive calculations on every render. +/// +/// +/// Remember that the function passed to [useMemo] runs during rendering. Don’t +/// do anything there that you wouldn’t normally do while rendering. For +/// example, side effects belong in [useLayoutEffect], not [useMemo]. +/// +/// +/// If no [dependencies] provided, a new value will be computed on every render. +/// +T useMemo( + T Function() create, [ + List? dependencies, +]) { + var useMemoHook = useHook(); + useMemoHook ??= setupHook(UseMemoHook()); + + if (useMemoHook is! UseMemoHook) { + throw Exception( + 'Expecting hook of type: $UseMemoHook ' + 'but got: ${useMemoHook.runtimeType}. ' + 'Please make sure your hooks call order is not dynamic.', + ); + } + + useMemoHook + ..updateDependencies(dependencies) + ..updateComputation(create); + + return useMemoHook.computationResult; +} + +@internal +class UseMemoHook extends DependenciesDrivenHook { + @nonVirtual + @protected + T get computationResult => _computationResult!; + T? _computationResult; + + @nonVirtual + @protected + void updateComputation(T Function() computation) { + if (null == _computationResult || super.areDependenciesChanged) { + _computationResult = computation(); + } + } +} From d0528d6301056c6fa2244a415d12b9292dbf30c8 Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:45:03 +0530 Subject: [PATCH 16/22] Add useCallback hook --- packages/rad_hooks/lib/rad_hooks.dart | 1 + packages/rad_hooks/lib/src/use_callback.dart | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 packages/rad_hooks/lib/src/use_callback.dart diff --git a/packages/rad_hooks/lib/rad_hooks.dart b/packages/rad_hooks/lib/rad_hooks.dart index 9aeec9f6..b689ea0b 100644 --- a/packages/rad_hooks/lib/rad_hooks.dart +++ b/packages/rad_hooks/lib/rad_hooks.dart @@ -13,3 +13,4 @@ export 'src/use_ref.dart' show useRef, UseRefHook; export 'src/use_effect.dart' show useEffect; export 'src/use_layout_effect.dart' show useLayoutEffect; export 'src/use_memo.dart' show useMemo; +export 'src/use_callback.dart' show useCallback; diff --git a/packages/rad_hooks/lib/src/use_callback.dart b/packages/rad_hooks/lib/src/use_callback.dart new file mode 100644 index 00000000..d247eae7 --- /dev/null +++ b/packages/rad_hooks/lib/src/use_callback.dart @@ -0,0 +1,18 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:rad_hooks/src/use_memo.dart'; + +/// Returns a memoized callback. +/// +/// Pass an inline [callback] and an array of [dependencies]. [useCallback] will +/// return a memoized version of the callback that only changes if one of the +/// dependencies has changed. +/// +T Function() useCallback( + T Function() callback, [ + List? dependencies, +]) { + return useMemo(() => callback, dependencies); +} From ad9a9a9388206d0cff1a2f9d9398800f50fc5710 Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:45:33 +0530 Subject: [PATCH 17/22] Add example and readme --- packages/rad_hooks/README.md | 40 ++++++++++++++++++++++++++++ packages/rad_hooks/example/README.md | 21 +++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 packages/rad_hooks/README.md create mode 100644 packages/rad_hooks/example/README.md diff --git a/packages/rad_hooks/README.md b/packages/rad_hooks/README.md new file mode 100644 index 00000000..05a28c85 --- /dev/null +++ b/packages/rad_hooks/README.md @@ -0,0 +1,40 @@ +## Rad Hooks + +[![Rad(hooks-pkg)](https://github.com/erlage/rad/actions/workflows/rad_hooks_pkg.yml/badge.svg)](https://github.com/erlage/rad/actions/workflows/rad_hooks_pkg.yml) + +A set of commonly used hooks for using in your Rad applications. + +### Basic Usage + +```dart +Widget someReusableWidget() => HookScope(() { + + // create a state variable + + var state = useState(1); + + return Span( + innerText: 'You clicked me ${state.value} times!', + onClick: (e) => state.value++, // <- update state(causes re-render) + ); +}); + +void main() { + runApp( + app: someReusableWidget(), + appTargetId: 'output', + ); +} +``` + +## Available Hooks + +For complete reference of available hooks, please refer: https://github.com/erlage/rad/edit/main/README.md#hooks + +## Creating custom hooks + +This package also serves as an example that show cases flexibility and power of hooks APIs in Rad. If you feel like missing a hook, you can just create it and hook it in your application. We recommend you to look into implementation of hooks included in this package for getting an idea on how to create your own hooks which then can be used directly in your Rad applications. + +## Contributing + +For reporting bugs/queries, feel free to open issue. Read [contributing guide](https://github.com/erlage/rad/blob/main/CONTRIBUTING.md) for more. diff --git a/packages/rad_hooks/example/README.md b/packages/rad_hooks/example/README.md new file mode 100644 index 00000000..a07bec5e --- /dev/null +++ b/packages/rad_hooks/example/README.md @@ -0,0 +1,21 @@ +### Sample: + +A basic example with useState hook: + +```dart +Widget widgetFunction() => HookScope(() { + // create a stateful value + var state = useState(0); + + return Span( + child: Text('You clicked me ${state.value} time!'), + onClick: (_) => state.value++, // will cause a re-render + ); +}); + +runApp(app: widgetFunction(), ...); +``` + +### Creating custom hooks + +Rad supports a easy yet powerful Hooks API that you can use to create your own hooks. There are number of hooks that we provide which you can use by importing the package [rad_hooks](https://pub.dev/packages/rad_hooks). If you want to create your own hooks, feel free check out implementations residing inside package. From d74da6d51bbf3593a542e58bf0ca317b527dda04 Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:45:59 +0530 Subject: [PATCH 18/22] Add tests for hooks package --- packages/rad_hooks/dart_test.yaml | 6 + packages/rad_hooks/test/basic_test.dart | 67 ++++++++ .../rad_hooks/test/use_callback_test.dart | 67 ++++++++ packages/rad_hooks/test/use_effect_test.dart | 149 +++++++++++++++++ .../test/use_layout_effect_test.dart | 155 ++++++++++++++++++ packages/rad_hooks/test/use_memo_test.dart | 63 +++++++ packages/rad_hooks/test/utils.dart | 46 ++++++ 7 files changed, 553 insertions(+) create mode 100644 packages/rad_hooks/dart_test.yaml create mode 100644 packages/rad_hooks/test/basic_test.dart create mode 100644 packages/rad_hooks/test/use_callback_test.dart create mode 100644 packages/rad_hooks/test/use_effect_test.dart create mode 100644 packages/rad_hooks/test/use_layout_effect_test.dart create mode 100644 packages/rad_hooks/test/use_memo_test.dart create mode 100644 packages/rad_hooks/test/utils.dart diff --git a/packages/rad_hooks/dart_test.yaml b/packages/rad_hooks/dart_test.yaml new file mode 100644 index 00000000..e4245607 --- /dev/null +++ b/packages/rad_hooks/dart_test.yaml @@ -0,0 +1,6 @@ +platforms: + - chrome + +tags: + browser: + test_on: browser diff --git a/packages/rad_hooks/test/basic_test.dart b/packages/rad_hooks/test/basic_test.dart new file mode 100644 index 00000000..c48071a7 --- /dev/null +++ b/packages/rad_hooks/test/basic_test.dart @@ -0,0 +1,67 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:rad/rad.dart'; +import 'package:rad_hooks/rad_hooks.dart'; +import 'package:rad_test/rad_test.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; + +void main() { + group('useState', () { + testWidgets('should cause re-render on value change', (tester) async { + await tester.pumpWidget( + HookScope(() { + var state = useState(0); + + return Text( + '${state.value}', + onClick: (_) => state.value++, + key: const Key('text'), + ); + }), + ); + + var domNode = tester.getDomNodeByKey(const Key('text')); + + expect(domNode, domNodeHasContents('0')); + domNode?.click(); + await Future.delayed(const Duration(milliseconds: 100)); + expect(domNode, domNodeHasContents('1')); + + domNode?.click(); + domNode?.click(); + await Future.delayed(const Duration(milliseconds: 100)); + expect(domNode, domNodeHasContents('3')); + }); + }); + + group('useRef', () { + testWidgets('should not cause re-render on value change', (tester) async { + await tester.pumpWidget( + HookScope(() { + var state = useRef(0); + + return Text( + '${state.value}', + onClick: (_) => state.value++, + key: const Key('text'), + ); + }), + ); + + var domNode = tester.getDomNodeByKey(const Key('text')); + + expect(domNode, domNodeHasContents('0')); + domNode?.click(); + await Future.delayed(const Duration(milliseconds: 100)); + expect(domNode, domNodeHasContents('0')); + + domNode?.click(); + domNode?.click(); + await Future.delayed(const Duration(milliseconds: 100)); + expect(domNode, domNodeHasContents('0')); + }); + }); +} diff --git a/packages/rad_hooks/test/use_callback_test.dart b/packages/rad_hooks/test/use_callback_test.dart new file mode 100644 index 00000000..4a908abe --- /dev/null +++ b/packages/rad_hooks/test/use_callback_test.dart @@ -0,0 +1,67 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: require_trailing_commas + +import 'package:rad/rad.dart'; +import 'package:rad_hooks/rad_hooks.dart'; +import 'package:rad_test/rad_test.dart'; +import 'package:test/expect.dart'; + +void main() { + testWidgets( + 'should update callback on each render if dependencies are null', + (tester) async { + var count = 0; + var callbacks = {}; + + while (count++ < 5) { + await tester.rePumpWidget(HookScope(() { + callbacks.add( + useCallback(() {}), + ); + + return const Text('hello world'); + })); + } + + expect(callbacks.length, equals(5)); + }, + ); + + testWidgets( + 'should update only when dependencies are changed', + (tester) async { + var callbacks = {}; + + Future rebuildWithDependencies([List? dependencies]) async { + await tester.rePumpWidget(HookScope(() { + callbacks.add( + useCallback(() {}, dependencies), + ); + + return const Text('hello world'); + })); + } + + await rebuildWithDependencies([]); + await rebuildWithDependencies([]); + await rebuildWithDependencies([]); + expect(callbacks.length, equals(1)); + callbacks.clear(); + + await rebuildWithDependencies([1, 2, 3]); + await rebuildWithDependencies([1, 2, 3]); + await rebuildWithDependencies([1, 2, 3, 4]); + expect(callbacks.length, equals(2)); + callbacks.clear(); + + await rebuildWithDependencies(null); + await rebuildWithDependencies(null); + await rebuildWithDependencies(null); + expect(callbacks.length, equals(3)); + callbacks.clear(); + }, + ); +} diff --git a/packages/rad_hooks/test/use_effect_test.dart b/packages/rad_hooks/test/use_effect_test.dart new file mode 100644 index 00000000..47ad4581 --- /dev/null +++ b/packages/rad_hooks/test/use_effect_test.dart @@ -0,0 +1,149 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: require_trailing_commas + +import 'package:rad/rad.dart'; +import 'package:rad_hooks/rad_hooks.dart'; +import 'package:rad_test/rad_test.dart'; +import 'package:test/expect.dart'; + +import 'utils.dart'; + +void main() { + testWidgets('should gets called after dom updates', (tester) async { + var isRendered = false; + await tester.pumpWidget( + HookScope(() { + useEffect(() { + expect(tester.getAppDomNode, domNodeHasContents('hello world')); + isRendered = true; + + return null; + }, []); + + return const Text('hello world'); + }), + ); + + expect(isRendered, equals(true)); + }); + + testWidgets('should clean after dom updates are flushed', (tester) async { + await tester.pumpWidget( + HookScope(() { + useEffect(() { + return () { + expect(tester.getAppDomNode, domNodeHasContents('dom is updated')); + }; + }, []); + + return const Text('hello world'); + }), + ); + + await tester.pumpWidget(const Text('dom is updated')); + }); + + testWidgets( + 'should gets called on each render if dependencies are null', + (tester) async { + var count = 0; + while (count++ < 5) { + await tester.rePumpWidget( + testUseScopedEffectsWidget([ + TestEffectCallback(() { + tester.push('$count'); + + return null; + }) + ]), + ); + } + + tester.assertMatchStack(['1', '2', '3', '4', '5']); + }, + ); + + testWidgets( + 'should run cleanup before re-rendering', + (tester) async { + Future rebuildWithCallback( + List> callbacks, + ) async { + await tester.rePumpWidget( + testUseScopedEffectsWidget(callbacks), + ); + } + + await rebuildWithCallback([ + TestEffectCallback(() { + tester.push('render 1'); + return () => tester.push('clean 1'); + }), + ]); + + await rebuildWithCallback([ + TestEffectCallback(() { + tester.push('render 2'); + return () => tester.push('clean 2'); + }, [1]), + ]); + + tester.assertMatchStack(['render 1', 'clean 1', 'render 2']); + + await rebuildWithCallback([ + TestEffectCallback(() { + tester.push('skipped render 3'); + return () => tester.push('skipped clean 3'); + }, [1]), + ]); + + await rebuildWithCallback([ + TestEffectCallback(() { + tester.push('render 4'); + return () => tester.push('clean 4'); + }, [2]), + ]); + + tester.assertMatchStack(['clean 2', 'render 4']); + + // will dispose hook scope + await tester.pumpWidget(const Text('hello world')); + tester.assertMatchStack(['clean 4']); + }, + ); + + testWidgets( + 'should render only when dependencies are changed', + (tester) async { + Future rebuildWithDependencies([List? dependencies]) async { + await tester.rePumpWidget( + testUseScopedEffectsWidget([ + TestEffectCallback(() { + tester.push('render'); + + return null; + }, dependencies) + ]), + ); + } + + await rebuildWithDependencies([]); + await rebuildWithDependencies([]); + await rebuildWithDependencies([]); + tester.assertMatchStack(List.generate(1, (_) => 'render')); + + await rebuildWithDependencies([1, 2, 3]); + await rebuildWithDependencies([1, 2, 3]); + await rebuildWithDependencies([1, 2, 3, 4]); + tester.assertMatchStack(List.generate(2, (_) => 'render')); + + await rebuildWithDependencies(null); + await rebuildWithDependencies(null); + await rebuildWithDependencies(null); + tester.assertMatchStack(List.generate(3, (_) => 'render')); + }, + ); +} diff --git a/packages/rad_hooks/test/use_layout_effect_test.dart b/packages/rad_hooks/test/use_layout_effect_test.dart new file mode 100644 index 00000000..8ab566f5 --- /dev/null +++ b/packages/rad_hooks/test/use_layout_effect_test.dart @@ -0,0 +1,155 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: require_trailing_commas + +import 'package:rad/rad.dart'; +import 'package:rad_hooks/rad_hooks.dart'; +import 'package:rad_hooks/src/use_layout_effect.dart'; +import 'package:rad_test/rad_test.dart'; +import 'package:test/expect.dart'; + +import 'utils.dart'; + +void main() { + testWidgets('should gets called before dom updates', (tester) async { + var isRendered = false; + await tester.pumpWidget(const Text('dom before update')); + + await tester.pumpWidget( + HookScope(() { + useLayoutEffect(() { + expect(tester.getAppDomNode, domNodeHasContents('dom before update')); + isRendered = true; + + return null; + }, []); + + return const Text('hello world'); + }), + ); + + expect(isRendered, equals(true)); + }); + + testWidgets('should clean before dom updates are flushed', (tester) async { + await tester.pumpWidget( + HookScope(() { + useLayoutEffect(() { + return () { + expect( + tester.getAppDomNode, + domNodeHasContents('dom before update'), + ); + }; + }, []); + + return const Text('dom before update'); + }), + ); + + await tester.pumpWidget(const Text('dom is updated')); + }); + + testWidgets( + 'should gets called on each render if dependencies are null', + (tester) async { + var count = 0; + while (count++ < 5) { + await tester.rePumpWidget( + testUseScopedLayoutEffectsWidget([ + TestEffectCallback(() { + tester.push('$count'); + + return null; + }) + ]), + ); + } + + tester.assertMatchStack(['1', '2', '3', '4', '5']); + }, + ); + + testWidgets( + 'should run cleanup before re-rendering', + (tester) async { + Future rebuildWithCallback( + List> callbacks, + ) async { + await tester.rePumpWidget( + testUseScopedLayoutEffectsWidget(callbacks), + ); + } + + await rebuildWithCallback([ + TestEffectCallback(() { + tester.push('render 1'); + return () => tester.push('clean 1'); + }), + ]); + + await rebuildWithCallback([ + TestEffectCallback(() { + tester.push('render 2'); + return () => tester.push('clean 2'); + }, [1]), + ]); + + tester.assertMatchStack(['render 1', 'clean 1', 'render 2']); + + await rebuildWithCallback([ + TestEffectCallback(() { + tester.push('skipped render 3'); + return () => tester.push('skipped clean 3'); + }, [1]), + ]); + + await rebuildWithCallback([ + TestEffectCallback(() { + tester.push('render 4'); + return () => tester.push('clean 4'); + }, [2]), + ]); + + tester.assertMatchStack(['clean 2', 'render 4']); + + // will dispose hook scope + await tester.pumpWidget(const Text('hello world')); + tester.assertMatchStack(['clean 4']); + }, + ); + + testWidgets( + 'should render only when dependencies are changed', + (tester) async { + Future rebuildWithDependencies([List? dependencies]) async { + await tester.rePumpWidget( + testUseScopedLayoutEffectsWidget([ + TestEffectCallback(() { + tester.push('render'); + + return null; + }, dependencies) + ]), + ); + } + + await rebuildWithDependencies([]); + await rebuildWithDependencies([]); + await rebuildWithDependencies([]); + tester.assertMatchStack(List.generate(1, (_) => 'render')); + + await rebuildWithDependencies([1, 2, 3]); + await rebuildWithDependencies([1, 2, 3]); + await rebuildWithDependencies([1, 2, 3, 4]); + tester.assertMatchStack(List.generate(2, (_) => 'render')); + + await rebuildWithDependencies(null); + await rebuildWithDependencies(null); + await rebuildWithDependencies(null); + tester.assertMatchStack(List.generate(3, (_) => 'render')); + }, + ); +} diff --git a/packages/rad_hooks/test/use_memo_test.dart b/packages/rad_hooks/test/use_memo_test.dart new file mode 100644 index 00000000..58df0c62 --- /dev/null +++ b/packages/rad_hooks/test/use_memo_test.dart @@ -0,0 +1,63 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: require_trailing_commas + +import 'package:rad/rad.dart'; +import 'package:rad_hooks/rad_hooks.dart'; +import 'package:rad_test/rad_test.dart'; + +void main() { + testWidgets( + 'should compute on each render if dependencies are null', + (tester) async { + var count = 0; + while (count++ < 5) { + await tester.rePumpWidget(HookScope(() { + useMemo(() { + tester.push('$count'); + + return 0; + }); + + return const Text('hello world'); + })); + } + + tester.assertMatchStack(['1', '2', '3', '4', '5']); + }, + ); + + testWidgets( + 'should compute only when dependencies are changed', + (tester) async { + Future rebuildWithDependencies([List? dependencies]) async { + await tester.rePumpWidget(HookScope(() { + useMemo(() { + tester.push('compute'); + + return 0; + }, dependencies); + + return const Text('hello world'); + })); + } + + await rebuildWithDependencies([]); + await rebuildWithDependencies([]); + await rebuildWithDependencies([]); + tester.assertMatchStack(List.generate(1, (_) => 'compute')); + + await rebuildWithDependencies([1, 2, 3]); + await rebuildWithDependencies([1, 2, 3]); + await rebuildWithDependencies([1, 2, 3, 4]); + tester.assertMatchStack(List.generate(2, (_) => 'compute')); + + await rebuildWithDependencies(null); + await rebuildWithDependencies(null); + await rebuildWithDependencies(null); + tester.assertMatchStack(List.generate(3, (_) => 'compute')); + }, + ); +} diff --git a/packages/rad_hooks/test/utils.dart b/packages/rad_hooks/test/utils.dart new file mode 100644 index 00000000..2a5d18cc --- /dev/null +++ b/packages/rad_hooks/test/utils.dart @@ -0,0 +1,46 @@ +// Copyright (c) 2022, Rad developers. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:rad/rad.dart'; +import 'package:rad_hooks/src/use_effect.dart'; +import 'package:rad_hooks/src/use_layout_effect.dart'; + +class TestEffectCallback { + final VoidCallback? Function() callback; + final List? dependencies; + + TestEffectCallback(this.callback, [this.dependencies]); +} + +Widget testUseScopedEffectsWidget( + List> effectCallbacks, +) => + HookScope(() { + for (final effectCallback in effectCallbacks) { + useEffect( + () { + return effectCallback.callback(); + }, + effectCallback.dependencies, + ); + } + + return const Text('hello world'); + }); + +Widget testUseScopedLayoutEffectsWidget( + List> effectCallbacks, +) => + HookScope(() { + for (final effectCallback in effectCallbacks) { + useLayoutEffect( + () { + return effectCallback.callback(); + }, + effectCallback.dependencies, + ); + } + + return const Text('hello world'); + }); From 3eaf8b8496b13a6e48f2e7f74df31deaba61b11a Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:46:10 +0530 Subject: [PATCH 19/22] Add workflow for testing hooks package --- .github/workflows/rad_hooks_pkg.yml | 45 +++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/rad_hooks_pkg.yml diff --git a/.github/workflows/rad_hooks_pkg.yml b/.github/workflows/rad_hooks_pkg.yml new file mode 100644 index 00000000..77822949 --- /dev/null +++ b/.github/workflows/rad_hooks_pkg.yml @@ -0,0 +1,45 @@ +name: Rad(hooks-pkg) + +on: + workflow_dispatch: + + push: + branches: [ main ] + paths: + - 'packages/rad/pubspec.yaml' + - 'packages/rad_hooks/**' + + pull_request: + branches: [ main ] + paths: + - 'packages/rad_hooks/**' + +jobs: + analyze: + runs-on: ubuntu-latest + name: Analyze + + steps: + - uses: actions/checkout@v3 + - uses: dart-lang/setup-dart@v1 + + - name: Run analyze + run: | + cd packages/rad_hooks + dart pub get + dart format --output=none --set-exit-if-changed . + dart analyze --fatal-infos + + tests: + runs-on: ubuntu-latest + name: Run tests + + steps: + - uses: actions/checkout@v3 + - uses: dart-lang/setup-dart@v1 + + - name: Run tests + run: | + cd packages/rad_hooks + dart pub get + dart test From e1b8d17def363f62330c409c98432bbf8ae37767 Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 7 Aug 2022 16:46:20 +0530 Subject: [PATCH 20/22] Update Readme(s) --- README.md | 116 ++++++++++++++++++------------------ packages/rad/README.md | 116 ++++++++++++++++++------------------ packages/rad/test/README.md | 1 + 3 files changed, 115 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index 93e03c7f..74ef05a8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Rad -Rad is a frontend framework for creating fast and interactive web apps using Dart. It's inspired from Flutter and shares same programming paradigm. It has all the best bits of Flutter(StatefulWidgets, Builders) and allows you to use web technologies(HTML and CSS) in your app. +Rad is a frontend framework for creating fast and interactive web apps using Dart. It has all the best bits of Flutter(StatefulWidgets, Builders) and React(Hooks, Performance), and allows you to use web technologies(HTML and CSS) in your app. [![Rad(core)](https://github.com/erlage/rad/actions/workflows/rad_core.yml/badge.svg)](https://github.com/erlage/rad/actions/workflows/rad_core.yml) [![Reconciler](https://github.com/erlage/rad/actions/workflows/reconciler.yml/badge.svg)](https://github.com/erlage/rad/actions/workflows/reconciler.yml) @@ -26,54 +26,7 @@ void main() { } ``` -Function `runApp` will finds a element having id equals to `appTargetId` in your HTML page, create a Rad app with it, and then displays "Text" widget inside of it. - -Let's see one more example, - -```dart -class HomePage extends StatelessWidget -{ - @override - Widget build(BuildContext context) { - return Text('hello world'); - } -} - -void main() { - runApp( - app: HomePage(), - appTargetId: 'output', - ); -} -``` - -If you're familiar with Flutter it don't even need an explanation. Internally, Rad has some differences that might not be apparent from the examples so let's discuss them first. - -## Differences - -1. First off, we don't use a rendering engine to render a widget or anything like that. Widgets are mapped to HTML tags and composed together the way you describe them. - -2. Second, you can use use CSS for adding animations without ever thinking about how browsers carries them out. - -3. And lastly, for layouts, you've to use HTML. And guess what? there are widgets for that. - - Let's take this HTML snippet: - ```html - - - hello world - - - ``` - Here's how its equivalent will be written using widgets: - ```dart - Span( - className: 'heading big', - children: [ - Strong(innerText: 'hello world'), - ], - ); - ``` +Function `runApp` will finds a element having `id=output` in your HTML page, create a Rad app with it, and then displays "hello world" inside of it. As you might have guessed it, `Text('hello world')` is a widget, a special purpose widget provided by the framework that we're using to display desired text on the screen. Rad provides number of widgets that you can use and best thing about widgets is that you can compose them together to create more widgets and build complex layouts. ## Flutter widgets @@ -84,15 +37,46 @@ Following widgets in Rad are inspired from Flutter: These widgets has same syntax as their Flutter's counterparts. Not just syntax, they also works exactly same as if they would in Flutter. Which means you don't have to learn anything new to be able to use them. +## React hooks + +Similar to React, we have number of hooks that you can use to power-up your widget functions. + +Let's see a basic example with useState: + +```dart +Widget widgetFunction() => HookScope(() { + // create a stateful value + var state = useState(0); + + return Span( + child: Text('You clicked me ${state.value} time!'), + onClick: (_) => state.value++, // will cause a re-render + ); +}); + +runApp(app: widgetFunction(), ...); +``` + +While using hooks please keep in mind following things, + +2. Avoid calling Hooks inside loops, conditions, or nested functions. +1. Always wrap body of your Widget-functions with a HookScope widget. +3. Always use Hooks at the top level of your functions, before any widget/or early return. + ## HTML widgets -Let's take a look at another markup example: +Similar to JSX, you can write HTML in your Dart code. Dart's syntax is much more safe than JSX and doesn't force you to go through a separate build step but writing plain HTML using Dart is not very ideal so Rad provides you with more than 100 widgets that are dedicated to help you write HTML within your Dart code as easily as possible. + +Let's look at this markup example: + ```html

Hey there!

``` -Here's how we'll write this using widgets: + +Here's how we'll write this using HTML widgets: + ```dart Division( children: [ @@ -100,7 +84,9 @@ Division( ] ) ``` -There's also an alternative syntax for HTML widgets: + +There's also an alternative syntax for all HTML widgets: + ```dart div( children: [ @@ -122,17 +108,18 @@ Span( ), ); ``` + In above example, a Span widget is containing a ListView widget. Further, that ListView is containing a StatefulWidget and a Span widget. The point we're trying to make is that HTML widgets won't restrict you to 'just HTML'. -## Widgets Index +## Reference -Below is the list of available widgets in this framework. Some widgets are named after Flutter widgets because they either works exactly same or can be used to achieve same things but in a different way(more or less). All those widgets are tagged accordingly. +Below is the list of available widgets and hooks in Rad. Some widgets are named after Flutter widgets because they either works exactly same or can be used to achieve same things but in a different way(more or less). All those widgets are tagged accordingly. Tags: - - (`6 widget[s]`) ***exact***: Exact syntax, similar semantics. - - (`3 widget[s]`) ***same***: Exact syntax with few exceptions, similar semantics. - - (`3 widget[s]`) ***different***: Different syntax, different semantics. - - (`1 widget[s]`) ***untested***: -- + - ***exact***: Exact syntax, similar semantics. + - ***same***: Exact syntax with few exceptions, similar semantics. + - ***different***: Different syntax, different semantics. + - ***untested***: -- ### Abstract @@ -158,14 +145,25 @@ Tags: - [RadApp](https://pub.dev/documentation/rad/latest/rad/RadApp-class.html) - [Text](https://pub.dev/documentation/rad/latest/rad/Text-class.html) \[*different*\] - [ListView](https://pub.dev/documentation/rad/latest/rad/ListView-class.html) \[*same*\] +- [HookScope](https://pub.dev/documentation/rad/latest/rad/HookScope-class.html) - [EventDetector](https://pub.dev/documentation/rad/latest/rad/EventDetector-class.html) +- [GestureDetector](https://pub.dev/documentation/rad/latest/rad/GestureDetector-class.html) \[*same*\] ### Misc - [RawMarkUp](https://pub.dev/documentation/rad/latest/rad/RawMarkUp-class.html) - [RawEventDetector](https://pub.dev/documentation/rad/latest/rad/RawEventDetector-class.html) -- [GestureDetector](https://pub.dev/documentation/rad/latest/rad/GestureDetector-class.html) \[*same*\] +### Hooks + +[useContext](https://pub.dev/documentation/rad/latest/rad/useContext.html) +, [useNavigator](https://pub.dev/documentation/rad/latest/rad/useNavigator.html) +, [useRef](https://pub.dev/documentation/rad_hooks/latest/rad_hooks/useRef.html) +, [useState](https://pub.dev/documentation/rad_hooks/latest/rad_hooks/useState.html) +, [useMemo](https://pub.dev/documentation/rad_hooks/latest/rad_hooks/useMemo.html) +, [useCallback](https://pub.dev/documentation/rad_hooks/latest/rad_hooks/useCallback.html) +, [useEffect](https://pub.dev/documentation/rad_hooks/latest/rad_hooks/useEffect.html) +, [useLayoutEffect](https://pub.dev/documentation/rad_hooks/latest/rad_hooks/useLayoutEffect.html) ### HTML Widgets (additional) diff --git a/packages/rad/README.md b/packages/rad/README.md index 93e03c7f..74ef05a8 100644 --- a/packages/rad/README.md +++ b/packages/rad/README.md @@ -1,6 +1,6 @@ # Rad -Rad is a frontend framework for creating fast and interactive web apps using Dart. It's inspired from Flutter and shares same programming paradigm. It has all the best bits of Flutter(StatefulWidgets, Builders) and allows you to use web technologies(HTML and CSS) in your app. +Rad is a frontend framework for creating fast and interactive web apps using Dart. It has all the best bits of Flutter(StatefulWidgets, Builders) and React(Hooks, Performance), and allows you to use web technologies(HTML and CSS) in your app. [![Rad(core)](https://github.com/erlage/rad/actions/workflows/rad_core.yml/badge.svg)](https://github.com/erlage/rad/actions/workflows/rad_core.yml) [![Reconciler](https://github.com/erlage/rad/actions/workflows/reconciler.yml/badge.svg)](https://github.com/erlage/rad/actions/workflows/reconciler.yml) @@ -26,54 +26,7 @@ void main() { } ``` -Function `runApp` will finds a element having id equals to `appTargetId` in your HTML page, create a Rad app with it, and then displays "Text" widget inside of it. - -Let's see one more example, - -```dart -class HomePage extends StatelessWidget -{ - @override - Widget build(BuildContext context) { - return Text('hello world'); - } -} - -void main() { - runApp( - app: HomePage(), - appTargetId: 'output', - ); -} -``` - -If you're familiar with Flutter it don't even need an explanation. Internally, Rad has some differences that might not be apparent from the examples so let's discuss them first. - -## Differences - -1. First off, we don't use a rendering engine to render a widget or anything like that. Widgets are mapped to HTML tags and composed together the way you describe them. - -2. Second, you can use use CSS for adding animations without ever thinking about how browsers carries them out. - -3. And lastly, for layouts, you've to use HTML. And guess what? there are widgets for that. - - Let's take this HTML snippet: - ```html - - - hello world - - - ``` - Here's how its equivalent will be written using widgets: - ```dart - Span( - className: 'heading big', - children: [ - Strong(innerText: 'hello world'), - ], - ); - ``` +Function `runApp` will finds a element having `id=output` in your HTML page, create a Rad app with it, and then displays "hello world" inside of it. As you might have guessed it, `Text('hello world')` is a widget, a special purpose widget provided by the framework that we're using to display desired text on the screen. Rad provides number of widgets that you can use and best thing about widgets is that you can compose them together to create more widgets and build complex layouts. ## Flutter widgets @@ -84,15 +37,46 @@ Following widgets in Rad are inspired from Flutter: These widgets has same syntax as their Flutter's counterparts. Not just syntax, they also works exactly same as if they would in Flutter. Which means you don't have to learn anything new to be able to use them. +## React hooks + +Similar to React, we have number of hooks that you can use to power-up your widget functions. + +Let's see a basic example with useState: + +```dart +Widget widgetFunction() => HookScope(() { + // create a stateful value + var state = useState(0); + + return Span( + child: Text('You clicked me ${state.value} time!'), + onClick: (_) => state.value++, // will cause a re-render + ); +}); + +runApp(app: widgetFunction(), ...); +``` + +While using hooks please keep in mind following things, + +2. Avoid calling Hooks inside loops, conditions, or nested functions. +1. Always wrap body of your Widget-functions with a HookScope widget. +3. Always use Hooks at the top level of your functions, before any widget/or early return. + ## HTML widgets -Let's take a look at another markup example: +Similar to JSX, you can write HTML in your Dart code. Dart's syntax is much more safe than JSX and doesn't force you to go through a separate build step but writing plain HTML using Dart is not very ideal so Rad provides you with more than 100 widgets that are dedicated to help you write HTML within your Dart code as easily as possible. + +Let's look at this markup example: + ```html

Hey there!

``` -Here's how we'll write this using widgets: + +Here's how we'll write this using HTML widgets: + ```dart Division( children: [ @@ -100,7 +84,9 @@ Division( ] ) ``` -There's also an alternative syntax for HTML widgets: + +There's also an alternative syntax for all HTML widgets: + ```dart div( children: [ @@ -122,17 +108,18 @@ Span( ), ); ``` + In above example, a Span widget is containing a ListView widget. Further, that ListView is containing a StatefulWidget and a Span widget. The point we're trying to make is that HTML widgets won't restrict you to 'just HTML'. -## Widgets Index +## Reference -Below is the list of available widgets in this framework. Some widgets are named after Flutter widgets because they either works exactly same or can be used to achieve same things but in a different way(more or less). All those widgets are tagged accordingly. +Below is the list of available widgets and hooks in Rad. Some widgets are named after Flutter widgets because they either works exactly same or can be used to achieve same things but in a different way(more or less). All those widgets are tagged accordingly. Tags: - - (`6 widget[s]`) ***exact***: Exact syntax, similar semantics. - - (`3 widget[s]`) ***same***: Exact syntax with few exceptions, similar semantics. - - (`3 widget[s]`) ***different***: Different syntax, different semantics. - - (`1 widget[s]`) ***untested***: -- + - ***exact***: Exact syntax, similar semantics. + - ***same***: Exact syntax with few exceptions, similar semantics. + - ***different***: Different syntax, different semantics. + - ***untested***: -- ### Abstract @@ -158,14 +145,25 @@ Tags: - [RadApp](https://pub.dev/documentation/rad/latest/rad/RadApp-class.html) - [Text](https://pub.dev/documentation/rad/latest/rad/Text-class.html) \[*different*\] - [ListView](https://pub.dev/documentation/rad/latest/rad/ListView-class.html) \[*same*\] +- [HookScope](https://pub.dev/documentation/rad/latest/rad/HookScope-class.html) - [EventDetector](https://pub.dev/documentation/rad/latest/rad/EventDetector-class.html) +- [GestureDetector](https://pub.dev/documentation/rad/latest/rad/GestureDetector-class.html) \[*same*\] ### Misc - [RawMarkUp](https://pub.dev/documentation/rad/latest/rad/RawMarkUp-class.html) - [RawEventDetector](https://pub.dev/documentation/rad/latest/rad/RawEventDetector-class.html) -- [GestureDetector](https://pub.dev/documentation/rad/latest/rad/GestureDetector-class.html) \[*same*\] +### Hooks + +[useContext](https://pub.dev/documentation/rad/latest/rad/useContext.html) +, [useNavigator](https://pub.dev/documentation/rad/latest/rad/useNavigator.html) +, [useRef](https://pub.dev/documentation/rad_hooks/latest/rad_hooks/useRef.html) +, [useState](https://pub.dev/documentation/rad_hooks/latest/rad_hooks/useState.html) +, [useMemo](https://pub.dev/documentation/rad_hooks/latest/rad_hooks/useMemo.html) +, [useCallback](https://pub.dev/documentation/rad_hooks/latest/rad_hooks/useCallback.html) +, [useEffect](https://pub.dev/documentation/rad_hooks/latest/rad_hooks/useEffect.html) +, [useLayoutEffect](https://pub.dev/documentation/rad_hooks/latest/rad_hooks/useLayoutEffect.html) ### HTML Widgets (additional) diff --git a/packages/rad/test/README.md b/packages/rad/test/README.md index f646d0bb..937c47ae 100644 --- a/packages/rad/test/README.md +++ b/packages/rad/test/README.md @@ -6,6 +6,7 @@ - tests/**framework-hc** - Core's widget building/updating tests (hard-coded). - tests/**patched** - Test cases of discovered issues. - tests/**services** - Various services's tests. +- tests/**hooks** - Tests related to hooks. - tests/**widgets** - Widget's specific tests. - tests/**misc** - Some miscellaneous tests. From 96bfe4cef44c40533486fba4e1d7d5fee7069c9f Mon Sep 17 00:00:00 2001 From: erlage Date: Tue, 9 Aug 2022 09:01:40 +0530 Subject: [PATCH 21/22] Enforce set/unset for scope interfaces There are plenty of ways a external scope implementation can abuse dispatcher. Setting scope and not unsetting it (or repeatedly setting it) is the easiest one that I think this change will prevent(if we ever decide to make scopes public). --- .../src/core/interface/scope/dispatcher.dart | 12 +++- .../rad/lib/src/widgets/render_scope.dart | 66 ++++++------------- 2 files changed, 32 insertions(+), 46 deletions(-) diff --git a/packages/rad/lib/src/core/interface/scope/dispatcher.dart b/packages/rad/lib/src/core/interface/scope/dispatcher.dart index c49142c1..b85ac90a 100644 --- a/packages/rad/lib/src/core/interface/scope/dispatcher.dart +++ b/packages/rad/lib/src/core/interface/scope/dispatcher.dart @@ -11,8 +11,18 @@ import 'package:rad/src/core/interface/scope/abstract.dart'; @internal Scope? getScope() => _currentScope; +/// Run a task under a provided scope interface. +/// @internal -void setScope(Scope? scope) => _currentScope = scope; +T runScopedTask(Scope scope, T Function() task) { + var previousScope = _currentScope; + _currentScope = scope; + + var results = task(); + + _currentScope = previousScope; + return results; +} // ----------------------------------------------------------- diff --git a/packages/rad/lib/src/widgets/render_scope.dart b/packages/rad/lib/src/widgets/render_scope.dart index 7c94812c..25e29048 100644 --- a/packages/rad/lib/src/widgets/render_scope.dart +++ b/packages/rad/lib/src/widgets/render_scope.dart @@ -69,28 +69,30 @@ class RenderScopeRenderElement extends RenderElement /// @nodoc @override List get widgetChildren { - if (_isInitialBuild) { - _dispatchEvent(ScopeEventType.willBuildScope); - } else { - _dispatchEvent(ScopeEventType.willRebuildScope); - } + return scope_unit.runScopedTask(this, () { + if (_isInitialBuild) { + _dispatchEvent(ScopeEventType.willBuildScope); + } else { + _dispatchEvent(ScopeEventType.willRebuildScope); + } - _isInBuildingPhase = true; - var widgetChildren = [(widget as RenderScope).builder()]; - _isInBuildingPhase = false; + _isInBuildingPhase = true; + var widgetChildren = [(widget as RenderScope).builder()]; + _isInBuildingPhase = false; - if (_isInitialBuild) { - _dispatchEvent(ScopeEventType.didBuildScope); - _isInitialBuild = false; - } else { - _dispatchEvent(ScopeEventType.didRebuildScope); - } + if (_isInitialBuild) { + _dispatchEvent(ScopeEventType.didBuildScope); + _isInitialBuild = false; + } else { + _dispatchEvent(ScopeEventType.didRebuildScope); + } - if (_isRebuildRequestPending) { - Future.delayed(Duration.zero, () => performRebuild()); - } + if (_isRebuildRequestPending) { + Future.delayed(Duration.zero, () => performRebuild()); + } - return widgetChildren; + return widgetChildren; + }); } @override @@ -117,11 +119,7 @@ class RenderScopeRenderElement extends RenderElement } _services.scheduler.addTask( - WidgetsUpdateDependentTask( - dependentRenderElement: this, - beforeTaskCallback: _setScope, - afterTaskCallback: _setScope, - ), + WidgetsUpdateDependentTask(dependentRenderElement: this), ); _isRebuildRequestPending = false; @@ -147,24 +145,6 @@ class RenderScopeRenderElement extends RenderElement }); } - /// @nodoc - @protected - @override - render({required widget}) { - _setScope(); - - return null; - } - - /// @nodoc - @protected - @override - update({required updateType, required oldWidget, required newWidget}) { - _setScope(); - - return null; - } - // ---------------------------------------------------------------------- // Internals // ---------------------------------------------------------------------- @@ -189,11 +169,7 @@ class RenderScopeRenderElement extends RenderElement /// Services get _services => resolveServices(this); - void _setScope() => scope_unit.setScope(this); - void _dispatchEvent(ScopeEventType eventType) { - _setScope(); - var listenersForType = _listeners[eventType]; if (null != listenersForType) { var event = ScopeEvent(eventType); From b992576afebb53d2bff950c18401d713a4d6dd66 Mon Sep 17 00:00:00 2001 From: erlage Date: Sun, 14 Aug 2022 10:11:02 +0530 Subject: [PATCH 22/22] Minor corrections in source comments --- packages/rad/lib/src/core/common/abstract/hook.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/rad/lib/src/core/common/abstract/hook.dart b/packages/rad/lib/src/core/common/abstract/hook.dart index fc1cd323..ac8c43fe 100644 --- a/packages/rad/lib/src/core/common/abstract/hook.dart +++ b/packages/rad/lib/src/core/common/abstract/hook.dart @@ -32,7 +32,6 @@ abstract class Hook { /// depend on the expected results of re-render request. /// /// - A call to [performRebuild] before or inside [register] is an error. - /// so hooks should not depend on results of rebuild. /// @protected @nonVirtual