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 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/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 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..ac8c43fe --- /dev/null +++ b/packages/rad/lib/src/core/common/abstract/hook.dart @@ -0,0 +1,96 @@ +// 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. + /// + @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/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); 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); 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..b85ac90a --- /dev/null +++ b/packages/rad/lib/src/core/interface/scope/dispatcher.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/interface/scope/abstract.dart'; + +/// Try getting current render scope. +/// +@internal +Scope? getScope() => _currentScope; + +/// Run a task under a provided scope interface. +/// +@internal +T runScopedTask(Scope scope, T Function() task) { + var previousScope = _currentScope; + _currentScope = scope; + + var results = task(); + + _currentScope = previousScope; + return results; +} + +// ----------------------------------------------------------- + +/// Currently executing render scope. +/// +Scope? _currentScope; 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/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/lib/src/widgets/render_scope.dart b/packages/rad/lib/src/widgets/render_scope.dart new file mode 100644 index 00000000..25e29048 --- /dev/null +++ b/packages/rad/lib/src/widgets/render_scope.dart @@ -0,0 +1,182 @@ +// 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 { + return scope_unit.runScopedTask(this, () { + 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), + ); + + _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, + ), + }); + } + + // ---------------------------------------------------------------------- + // 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 _dispatchEvent(ScopeEventType eventType) { + var listenersForType = _listeners[eventType]; + if (null != listenersForType) { + var event = ScopeEvent(eventType); + + for (final listener in listenersForType) { + listener(event); + } + } + } +} 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. 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/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)); + } } 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, + ); +} 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)); + }); +} 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)); + }); +} 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/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/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/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/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. diff --git a/packages/rad_hooks/lib/rad_hooks.dart b/packages/rad_hooks/lib/rad_hooks.dart new file mode 100644 index 00000000..b689ea0b --- /dev/null +++ b/packages/rad_hooks/lib/rad_hooks.dart @@ -0,0 +1,16 @@ +/// A set of commonly used Hooks for using in your Rad applications. +/// +library rad_hooks; + +/* +|-------------------------------------------------------------------------- +| 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; +export 'src/use_memo.dart' show useMemo; +export 'src/use_callback.dart' show useCallback; 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_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); +} 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(); + } + } +} 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(); + } + } +} 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(); + } + } +} 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); +} 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); +} 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 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'); + });