Skip to content

Commit

Permalink
Updated view model hooks
Browse files Browse the repository at this point in the history
  • Loading branch information
Andrei Fangli committed Sep 17, 2024
1 parent e640572 commit 22e1a0c
Show file tree
Hide file tree
Showing 10 changed files with 524 additions and 163 deletions.
100 changes: 100 additions & 0 deletions src/hooks/UseViewModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { INotifyPropertiesChanged, IPropertiesChangedEventHandler } from '../viewModels';
import { useState, useEffect, useRef } from 'react';
import { isViewModel } from '../viewModels';

const emptyConstructorArgs: readonly unknown[] = [];

/**
* Represents a view model type.
* @template TViewModel The type of view model.
* @template TConstructorArgs The constructor parameter types, defaults to an empty tuple.
*/
export type ViewModelType<TViewModel extends INotifyPropertiesChanged, TConstructorArgs extends readonly any[] = []> = {
new(...constructorArgs: TConstructorArgs): TViewModel;
};

/**
* Watches the given view model for property changes.
* @template TViewModel The type of view model.
* @param viewModel The view model to watch.
* @returns Returns the provided view model instance.
*/
export function useViewModel<TViewModel extends INotifyPropertiesChanged>(viewModel: TViewModel): TViewModel;

/**
* Creates a new instance of a view model of the given type and watches for property changes.
* @template TViewModel The type of view model.
* @param viewModelType The view model class declaration to instantiate.
* @param constructorArgs The constructor arguments used for initialization, whenever these change a new instance is created.
* @returns Returns the created view model instance.
*/
export function useViewModel<TViewModel extends INotifyPropertiesChanged>(viewModelType: ViewModelType<TViewModel>): TViewModel;

/**
* Creates a new instance of a view model of the given type and watches for property changes, constructor arguments act as dependencies.
* @template TViewModel The type of view model.
* @template TConstructorArgs The constructor parameter types.
* @param viewModelType The view model class declaration to instantiate.
* @param constructorArgs The constructor arguments used for initialization, whenever these change a new instance is created.
* @returns Returns the created view model instance.
*/
export function useViewModel<TViewModel extends INotifyPropertiesChanged, TConstructorArgs extends readonly any[]>(viewModelType: ViewModelType<TViewModel, TConstructorArgs>, constructorArgs: TConstructorArgs): TViewModel;

/**
* Watches the provided view model, or creates a new instance of the given type and watches it for property changes, constructor arguments act as dependencies.
* @template TViewModel The type of view model.
* @template TConstructorArgs The constructor parameter types.
* @param viewModelType The view model or class declaration to instantiate.
* @param constructorArgs The constructor arguments used for initialization, whenever these change a new instance is created.
* @returns Returns the provided view model or the initialized one.
*/
export function useViewModel<TViewModel extends INotifyPropertiesChanged, TConstructorArgs extends readonly any[]>(viewModelOrType: TViewModel | ViewModelType<TViewModel, TConstructorArgs>, constructorArgs: TConstructorArgs): TViewModel;

export function useViewModel<TViewModel extends INotifyPropertiesChanged, TConstructorArgs extends readonly any[]>(viewModelOrType: TViewModel | ViewModelType<TViewModel, TConstructorArgs>, constructorArgs?: TConstructorArgs): TViewModel {
const [, setState] = useState<unknown>(null);

const viewModelPropsRef = useRef<Map<keyof TViewModel, unknown> | null>(null);
if (viewModelPropsRef.current === null)
viewModelPropsRef.current = new Map<keyof TViewModel, unknown>();
const { current: cachedViewModelPropertyValues } = viewModelPropsRef;

const viewModelRef = useRef<TViewModel | null>(null)
const normalizedConstructorArgs = constructorArgs === null || constructorArgs === undefined || !Array.isArray(constructorArgs) ? emptyConstructorArgs as TConstructorArgs : constructorArgs;
const cachedConstructorArgsRef = useRef<TConstructorArgs>(normalizedConstructorArgs);

if (viewModelRef.current === null
|| cachedConstructorArgsRef.current.length !== normalizedConstructorArgs.length
|| cachedConstructorArgsRef.current.some((constructorArg, constructorArgIndex) => constructorArg !== normalizedConstructorArgs[constructorArgIndex])) {
cachedConstructorArgsRef.current = normalizedConstructorArgs.slice() as any as TConstructorArgs;
viewModelRef.current = isViewModel(viewModelOrType) ? viewModelOrType : new viewModelOrType(...normalizedConstructorArgs);
}
const { current: viewModel } = viewModelRef;

useEffect(
() => {
const viewModelPropertiesChangedEventHandler: IPropertiesChangedEventHandler<TViewModel> = {
handle(_, changedProperties) {
let hasChanges = false;
changedProperties.forEach(changedProperty => {
const viewModelPropertyValue = viewModel[changedProperty];
hasChanges = hasChanges || cachedViewModelPropertyValues.get(changedProperty) !== viewModelPropertyValue;

cachedViewModelPropertyValues.set(changedProperty, viewModelPropertyValue);
});

if (hasChanges)
setState({});
}
};

viewModel.propertiesChanged.subscribe(viewModelPropertiesChangedEventHandler);
return () => {
viewModel.propertiesChanged.unsubscribe(viewModelPropertiesChangedEventHandler);
cachedViewModelPropertyValues.clear();
}
},
[viewModel, cachedViewModelPropertyValues, setState]
)

return viewModel;
}
36 changes: 36 additions & 0 deletions src/hooks/UseViewModelMemo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { INotifyPropertiesChanged } from '../viewModels';
import { type DependencyList, type useMemo, useRef } from 'react';
import { useViewModel } from './UseViewModel';

const emptyDeps: DependencyList = [];

/**
* Represents a view model factory callback.
* @template TViewModel The type of view model to create.
*/
export type ViewModelFactory<TViewModel extends INotifyPropertiesChanged> = () => TViewModel;

/**
* Ensures a view models instance per component generated by the factory and watched for changes. Whenever the provided deps
* change a new instance is created, similar to {@link useMemo}.
* @template TViewModel The type of view model to create.
* @param viewModelFactory The view model factory callback for creating an instance.
* @param deps Dependencies of the callback, whenever these change the callback is called again, similar to {@link useMemo}.
* @returns Returns the created view model instance.
*/
export function useViewModelMemo<TViewModel extends INotifyPropertiesChanged>(viewModelFactory: ViewModelFactory<TViewModel>, deps: DependencyList): TViewModel {
const normalizedDeps = deps === null || deps === undefined || !Array.isArray(deps) ? emptyDeps : deps;

const cachedDependencies = useRef(normalizedDeps);
const viewModelRef = useRef<TViewModel | null>(null);

if (viewModelRef.current === null || cachedDependencies.current.length !== normalizedDeps.length || cachedDependencies.current.some((cachedDependency, dependencyIndex) => cachedDependency !== normalizedDeps[dependencyIndex])) {
cachedDependencies.current = normalizedDeps.slice();
viewModelRef.current = viewModelFactory();
}

const { current: viewModel } = viewModelRef;
useViewModel(viewModel);

return viewModel;
}
2 changes: 2 additions & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { type ViewModelType, useViewModel } from './UseViewModel';
export { type ViewModelFactory, useViewModelMemo } from './UseViewModelMemo';
183 changes: 183 additions & 0 deletions src/hooks/tests/UseViewModel.tests.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { render } from '@testing-library/react';
import { type INotifyPropertiesChanged, ViewModel } from '../../viewModels';
import { type ViewModelType, useViewModel } from '../UseViewModel';

describe('useViewModel', (): void => {
interface ITestComponentProps<TViewModel extends INotifyPropertiesChanged, TConstructorArgs extends readonly any[]> {
readonly viewModelOrType: TViewModel | ViewModelType<TViewModel, TConstructorArgs>;
readonly constructorArgs: TConstructorArgs;

children(viewModel: TViewModel): JSX.Element;
}

function TestComponent<TViewModel extends INotifyPropertiesChanged, TConstructorArgs extends readonly any[]>({ viewModelOrType, constructorArgs, children }: ITestComponentProps<TViewModel, TConstructorArgs>): JSX.Element {
const viewModel = useViewModel(viewModelOrType, constructorArgs);

return children(viewModel);
}

it('updating the view model does not create a new instance', () => {
const viewModels: TestCaseViewModel[] = [];
class TestCaseViewModel extends ViewModel {
private _value: number = 0;

public constructor() {
super();

viewModels.push(this);
}

public get value(): number {
return this._value;
}

public increment(): void {
this._value++;
this.notifyPropertiesChanged("value");
}
}

const { getByText } = render(
<TestComponent
viewModelOrType={TestCaseViewModel}
constructorArgs={[]}>
{viewModel => (
<>
Value: {viewModel.value}
</>
)}
</TestComponent>
);
expect(getByText('Value: 0')).not.toBe(undefined);

act(() => {
viewModels.forEach(viewModel => viewModel.increment());
});

expect(getByText('Value: 1')).not.toBe(undefined);
expect(viewModels.length).toBe(1);
});

it('changing the constructor arguments creates a new instance', () => {
const viewModels: TestCaseViewModel[] = [];
class TestCaseViewModel extends ViewModel {
public constructor(value: number) {
super();

this.value = value;
viewModels.push(this);
}

public readonly value: number;

public notifyPropertiesChanged(): void {
super.notifyPropertiesChanged("value");
}
}

const constructorArgs: [number] = [0]

const { getByText } = render(
<TestComponent
viewModelOrType={TestCaseViewModel}
constructorArgs={constructorArgs}>
{viewModel => (
<>
Value: {viewModel.value}
</>
)}
</TestComponent>
);
expect(getByText('Value: 0')).not.toBe(undefined);

act(() => {
constructorArgs[0] = 1;
viewModels.forEach(viewModel => viewModel.notifyPropertiesChanged());
});

expect(getByText('Value: 1')).not.toBe(undefined);
expect(viewModels.length).toBe(2);
});

it('component reacts to view model changes', () => {
class TestCaseViewModel extends ViewModel {
private _value: number = 0;

public get value(): number {
return this._value;
}

public increment(): void {
this._value++;
this.notifyPropertiesChanged("value");
}
}

const viewModel = new TestCaseViewModel();

const { getByText } = render(
<TestComponent
viewModelOrType={viewModel}
constructorArgs={[]}>
{viewModel => (
<>
Value: {viewModel.value}
</>
)}
</TestComponent>
);
expect(getByText('Value: 0')).not.toBe(undefined);

act(() => {
viewModel.increment();
});

expect(getByText('Value: 1')).not.toBe(undefined);
});

it('successive view model changes without actually changing anything does not cause re-render', () => {
class TestCaseViewModel extends ViewModel {
private _value: number = 0;

public get value(): number {
return this._value;
}

public set value(value: number) {
this._value = value;
this.notifyPropertiesChanged("value");
}
}

let renderCount = 0;
const viewModel = new TestCaseViewModel();

const { getByText } = render(
<TestComponent
viewModelOrType={viewModel}
constructorArgs={[]}>
{viewModel => {
renderCount++;

return (
<>
Value: {viewModel.value}
</>
);
}}
</TestComponent>
);
expect(getByText('Value: 0')).not.toBe(undefined);

act(() => {
viewModel.value = 0;
viewModel.value = 0;
viewModel.value = 0;
});

expect(getByText('Value: 0')).not.toBe(undefined);
expect(renderCount).toBe(2);
});
});
Loading

0 comments on commit 22e1a0c

Please sign in to comment.