Refactor watch sources for reliability

This commit changes `WatchSource` signatures into `Readonly<Ref>`s.

It provides two important benefits:

1. Eliminates the possibility of `undefined` states, that's result of
   using `WatchSource`s. This previously required additional null checks.
   By using `Readonly<Ref>`, the state handling becomes simpler and less
   susceptible to null errors.
2. Optimizes performance by using references:
   - Avoids the reactive layer of `computed` references when not needed.
   - The `watch` syntax, such as `watch(() => ref.value)`, can introduce
     side effects. For example, it does not account for `triggerRef` in
     scenarios where the value remains unchanged, preventing the watcher
     from running (vuejs/core#9579).
This commit is contained in:
undergroundwires
2023-11-11 13:55:21 +01:00
parent 58cd551a30
commit 7ab16ecccb
25 changed files with 190 additions and 217 deletions

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import {
shallowRef, defineComponent, WatchSource, nextTick,
shallowRef, defineComponent, nextTick, type Ref,
} from 'vue';
import { shallowMount } from '@vue/test-utils';
import { ReadOnlyTreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
@@ -16,31 +16,22 @@ describe('useNodeState', () => {
it('should set state on immediate invocation if node exists', () => {
// arrange
const expectedState = new TreeNodeStateDescriptorStub();
const nodeWatcher = shallowRef<ReadOnlyTreeNode | undefined>(undefined);
nodeWatcher.value = new TreeNodeStub()
.withState(new TreeNodeStateAccessStub().withCurrent(expectedState));
const nodeRef = shallowRef<ReadOnlyTreeNode>(
new TreeNodeStub().withState(new TreeNodeStateAccessStub().withCurrent(expectedState)),
);
// act
const { returnObject } = mountWrapperComponent(nodeWatcher);
const { returnObject } = mountWrapperComponent(nodeRef);
// assert
expect(returnObject.state.value).to.equal(expectedState);
});
it('should not set state on immediate invocation if node is undefined', () => {
// arrange
const nodeWatcher = shallowRef<ReadOnlyTreeNode | undefined>(undefined);
// act
const { returnObject } = mountWrapperComponent(nodeWatcher);
// assert
expect(returnObject.state.value).toBeUndefined();
});
it('should update state when nodeWatcher changes', async () => {
it('should update state when node changes', async () => {
// arrange
const expectedNewState = new TreeNodeStateDescriptorStub();
const nodeWatcher = shallowRef<ReadOnlyTreeNode | undefined>(undefined);
const { returnObject } = mountWrapperComponent(nodeWatcher);
const nodeRef = shallowRef<ReadOnlyTreeNode>(new TreeNodeStub());
const { returnObject } = mountWrapperComponent(nodeRef);
// act
nodeWatcher.value = new TreeNodeStub()
nodeRef.value = new TreeNodeStub()
.withState(new TreeNodeStateAccessStub().withCurrent(expectedNewState));
await nextTick();
// assert
@@ -49,30 +40,28 @@ describe('useNodeState', () => {
it('should update state when node state changes', () => {
// arrange
const nodeWatcher = shallowRef<ReadOnlyTreeNode | undefined>(undefined);
const stateAccessStub = new TreeNodeStateAccessStub();
const nodeRef = shallowRef<ReadOnlyTreeNode>(
new TreeNodeStub().withState(stateAccessStub),
);
const expectedChangedState = new TreeNodeStateDescriptorStub();
nodeWatcher.value = new TreeNodeStub()
.withState(stateAccessStub);
// act
const { returnObject } = mountWrapperComponent(nodeWatcher);
const { returnObject } = mountWrapperComponent(nodeRef);
stateAccessStub.triggerStateChangedEvent(
new NodeStateChangedEventStub()
.withNewState(expectedChangedState),
);
// assert
expect(returnObject.state.value).to.equal(expectedChangedState);
});
});
function mountWrapperComponent(nodeWatcher: WatchSource<ReadOnlyTreeNode | undefined>) {
function mountWrapperComponent(nodeRef: Readonly<Ref<ReadOnlyTreeNode>>) {
let returnObject: ReturnType<typeof useNodeState>;
const wrapper = shallowMount(
defineComponent({
setup() {
returnObject = useNodeState(nodeWatcher);
returnObject = useNodeState(nodeRef);
},
template: '<div></div>',
}),

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { WatchSource } from 'vue';
import { type Ref, shallowRef } from 'vue';
import { useGradualNodeRendering } from '@/presentation/components/Scripts/View/Tree/TreeView/Rendering/UseGradualNodeRendering';
import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot';
import { TreeRootStub } from '@tests/unit/shared/Stubs/TreeRootStub';
@@ -17,18 +17,18 @@ import { RenderQueueOrdererStub } from '@tests/unit/shared/Stubs/RenderQueueOrde
import { RenderQueueOrderer } from '@/presentation/components/Scripts/View/Tree/TreeView/Rendering/Ordering/RenderQueueOrderer';
describe('useGradualNodeRendering', () => {
it('watches nodes on specified tree', () => {
it('tracks nodes on specified tree', () => {
// arrange
const expectedWatcher = () => new TreeRootStub();
const expectedTreeRootRef = shallowRef(new TreeRootStub());
const currentTreeNodesStub = new UseCurrentTreeNodesStub();
const builder = new UseGradualNodeRenderingBuilder()
.withCurrentTreeNodes(currentTreeNodesStub)
.withTreeWatcher(expectedWatcher);
.withTreeRootRef(expectedTreeRootRef);
// act
builder.call();
// assert
const actualWatcher = currentTreeNodesStub.treeWatcher;
expect(actualWatcher).to.equal(expectedWatcher);
const actualTreeRootRef = currentTreeNodesStub.treeRootRef;
expect(actualTreeRootRef).to.equal(expectedTreeRootRef);
});
describe('shouldRender', () => {
describe('on visibility toggle', () => {
@@ -269,7 +269,7 @@ function createNodeWithVisibility(
class UseGradualNodeRenderingBuilder {
private changeAggregator = new UseNodeStateChangeAggregatorStub();
private treeWatcher: WatchSource<TreeRoot | undefined> = () => new TreeRootStub();
private treeRootRef: Readonly<Ref<TreeRoot>> = shallowRef(new TreeRootStub());
private currentTreeNodes = new UseCurrentTreeNodesStub();
@@ -291,8 +291,8 @@ class UseGradualNodeRenderingBuilder {
return this;
}
public withTreeWatcher(treeWatcher: WatchSource<TreeRoot | undefined>): this {
this.treeWatcher = treeWatcher;
public withTreeRootRef(treeRootRef: Readonly<Ref<TreeRoot>>): this {
this.treeRootRef = treeRootRef;
return this;
}
@@ -318,7 +318,7 @@ class UseGradualNodeRenderingBuilder {
public call(): ReturnType<typeof useGradualNodeRendering> {
return useGradualNodeRendering(
this.treeWatcher,
this.treeRootRef,
this.changeAggregator.get(),
this.currentTreeNodes.get(),
this.delayScheduler,

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { WatchSource } from 'vue';
import { type Ref, shallowRef } from 'vue';
import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot';
import { useAutoUpdateChildrenCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateChildrenCheckState';
import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState';
@@ -25,16 +25,16 @@ describe('useAutoUpdateChildrenCheckState', () => {
});
it('aggregate changes on specified tree', () => {
// arrange
const expectedWatcher = () => new TreeRootStub();
const expectedTreeRoot = shallowRef(new TreeRootStub());
const aggregatorStub = new UseNodeStateChangeAggregatorStub();
const builder = new UseAutoUpdateChildrenCheckStateBuilder()
.withChangeAggregator(aggregatorStub)
.withTreeWatcher(expectedWatcher);
.withTreeRoot(expectedTreeRoot);
// act
builder.call();
// assert
const actualWatcher = aggregatorStub.treeWatcher;
expect(actualWatcher).to.equal(expectedWatcher);
const actualTreeRoot = aggregatorStub.treeRootRef;
expect(actualTreeRoot).to.equal(expectedTreeRoot);
});
describe('skips event handling', () => {
const scenarios: ReadonlyArray<{
@@ -197,21 +197,21 @@ function getAllPossibleCheckStates() {
class UseAutoUpdateChildrenCheckStateBuilder {
private changeAggregator = new UseNodeStateChangeAggregatorStub();
private treeWatcher: WatchSource<TreeRoot | undefined> = () => new TreeRootStub();
private treeRoot: Readonly<Ref<TreeRoot>> = shallowRef(new TreeRootStub());
public withChangeAggregator(changeAggregator: UseNodeStateChangeAggregatorStub): this {
this.changeAggregator = changeAggregator;
return this;
}
public withTreeWatcher(treeWatcher: WatchSource<TreeRoot | undefined>): this {
this.treeWatcher = treeWatcher;
public withTreeRoot(treeRoot: Readonly<Ref<TreeRoot>>): this {
this.treeRoot = treeRoot;
return this;
}
public call(): ReturnType<typeof useAutoUpdateChildrenCheckState> {
return useAutoUpdateChildrenCheckState(
this.treeWatcher,
this.treeRoot,
this.changeAggregator.get(),
);
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { WatchSource } from 'vue';
import { type Ref, shallowRef } from 'vue';
import { UseNodeStateChangeAggregatorStub } from '@tests/unit/shared/Stubs/UseNodeStateChangeAggregatorStub';
import { TreeRootStub } from '@tests/unit/shared/Stubs/TreeRootStub';
import { useAutoUpdateParentCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateParentCheckState';
@@ -24,16 +24,16 @@ describe('useAutoUpdateParentCheckState', () => {
});
it('aggregate changes on specified tree', () => {
// arrange
const expectedWatcher = () => new TreeRootStub();
const expectedTreeRootRef = shallowRef(new TreeRootStub());
const aggregatorStub = new UseNodeStateChangeAggregatorStub();
const builder = new UseAutoUpdateParentCheckStateBuilder()
.withChangeAggregator(aggregatorStub)
.withTreeWatcher(expectedWatcher);
.withTreeRootRef(expectedTreeRootRef);
// act
builder.call();
// assert
const actualWatcher = aggregatorStub.treeWatcher;
expect(actualWatcher).to.equal(expectedWatcher);
const actualTreeRootRef = aggregatorStub.treeRootRef;
expect(actualTreeRootRef).to.equal(expectedTreeRootRef);
});
it('does not throw if node has no parent', () => {
// arrange
@@ -185,21 +185,21 @@ describe('useAutoUpdateParentCheckState', () => {
class UseAutoUpdateParentCheckStateBuilder {
private changeAggregator = new UseNodeStateChangeAggregatorStub();
private treeWatcher: WatchSource<TreeRoot | undefined> = () => new TreeRootStub();
private treeRootRef: Readonly<Ref<TreeRoot>> = shallowRef(new TreeRootStub());
public withChangeAggregator(changeAggregator: UseNodeStateChangeAggregatorStub): this {
this.changeAggregator = changeAggregator;
return this;
}
public withTreeWatcher(treeWatcher: WatchSource<TreeRoot | undefined>): this {
this.treeWatcher = treeWatcher;
public withTreeRootRef(treeRootRef: Readonly<Ref<TreeRoot>>): this {
this.treeRootRef = treeRootRef;
return this;
}
public call(): ReturnType<typeof useAutoUpdateParentCheckState> {
return useAutoUpdateParentCheckState(
this.treeWatcher,
this.treeRootRef,
this.changeAggregator.get(),
);
}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import {
shallowRef, defineComponent, WatchSource, nextTick,
shallowRef, defineComponent, nextTick, type Ref,
} from 'vue';
import { shallowMount } from '@vue/test-utils';
import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot';
@@ -15,25 +15,25 @@ describe('useCurrentTreeNodes', () => {
it('should set nodes on immediate invocation', () => {
// arrange
const expectedNodes = new QueryableNodesStub();
const treeWatcher = shallowRef<TreeRoot>(new TreeRootStub().withCollection(
const treeRootRef = shallowRef(new TreeRootStub().withCollection(
new TreeNodeCollectionStub().withNodes(expectedNodes),
));
// act
const { returnObject } = mountWrapperComponent(treeWatcher);
const { returnObject } = mountWrapperComponent(treeRootRef);
// assert
expect(returnObject.nodes.value).to.deep.equal(expectedNodes);
});
it('should update nodes when treeWatcher changes', async () => {
it('should update nodes when tree root changes', async () => {
// arrange
const initialNodes = new QueryableNodesStub();
const treeWatcher = shallowRef(
const treeRootRef = shallowRef(
new TreeRootStub().withCollection(new TreeNodeCollectionStub().withNodes(initialNodes)),
);
const { returnObject } = mountWrapperComponent(treeWatcher);
const { returnObject } = mountWrapperComponent(treeRootRef);
const newExpectedNodes = new QueryableNodesStub();
// act
treeWatcher.value = new TreeRootStub().withCollection(
treeRootRef.value = new TreeRootStub().withCollection(
new TreeNodeCollectionStub().withNodes(newExpectedNodes),
);
await nextTick();
@@ -45,9 +45,9 @@ describe('useCurrentTreeNodes', () => {
// arrange
const initialNodes = new QueryableNodesStub();
const treeCollectionStub = new TreeNodeCollectionStub().withNodes(initialNodes);
const treeWatcher = shallowRef(new TreeRootStub().withCollection(treeCollectionStub));
const treeRootRef = shallowRef(new TreeRootStub().withCollection(treeCollectionStub));
const { returnObject } = mountWrapperComponent(treeWatcher);
const { returnObject } = mountWrapperComponent(treeRootRef);
const newExpectedNodes = new QueryableNodesStub();
// act
@@ -58,12 +58,12 @@ describe('useCurrentTreeNodes', () => {
});
});
function mountWrapperComponent(treeWatcher: WatchSource<TreeRoot | undefined>) {
function mountWrapperComponent(treeRootRef: Ref<TreeRoot>) {
let returnObject: ReturnType<typeof useCurrentTreeNodes>;
const wrapper = shallowMount(
defineComponent({
setup() {
returnObject = useCurrentTreeNodes(treeWatcher);
returnObject = useCurrentTreeNodes(treeRootRef);
},
template: '<div></div>',
}),

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { WatchSource, defineComponent, nextTick } from 'vue';
import { defineComponent, nextTick, shallowRef } from 'vue';
import { shallowMount } from '@vue/test-utils';
import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot';
import { useCurrentTreeNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/UseCurrentTreeNodes';
@@ -17,20 +17,21 @@ import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeSt
import { TreeNodeStateAccessStub } from '@tests/unit/shared/Stubs/TreeNodeStateAccessStub';
import { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
import { FunctionKeys } from '@/TypeHelpers';
import type { Ref } from 'vue';
describe('useNodeStateChangeAggregator', () => {
it('watches nodes on specified tree', () => {
it('tracks nodes on specified tree', () => {
// arrange
const expectedWatcher = () => new TreeRootStub();
const expectedTreeRootRef = shallowRef(new TreeRootStub());
const currentTreeNodesStub = new UseCurrentTreeNodesStub();
const builder = new UseNodeStateChangeAggregatorBuilder()
.withCurrentTreeNodes(currentTreeNodesStub.get())
.withTreeWatcher(expectedWatcher);
.withTreeRootRef(expectedTreeRootRef);
// act
builder.mountWrapperComponent();
// assert
const actualWatcher = currentTreeNodesStub.treeWatcher;
expect(actualWatcher).to.equal(expectedWatcher);
const actualTreeRootRef = currentTreeNodesStub.treeRootRef;
expect(actualTreeRootRef).to.equal(expectedTreeRootRef);
});
describe('onNodeStateChange', () => {
describe('throws if callback is absent', () => {
@@ -302,14 +303,14 @@ function createFlatCollection(nodes: readonly TreeNode[]): QueryableNodesStub {
}
class UseNodeStateChangeAggregatorBuilder {
private treeWatcher: WatchSource<TreeRoot | undefined> = () => new TreeRootStub();
private treeRootRef: Readonly<Ref<TreeRoot>> = shallowRef(new TreeRootStub());
private currentTreeNodes: typeof useCurrentTreeNodes = new UseCurrentTreeNodesStub().get();
private events: UseAutoUnsubscribedEventsStub = new UseAutoUnsubscribedEventsStub();
public withTreeWatcher(treeWatcher: WatchSource<TreeRoot | undefined>): this {
this.treeWatcher = treeWatcher;
public withTreeRootRef(treeRootRef: Readonly<Ref<TreeRoot>>): this {
this.treeRootRef = treeRootRef;
return this;
}
@@ -325,11 +326,11 @@ class UseNodeStateChangeAggregatorBuilder {
public mountWrapperComponent() {
let returnObject: ReturnType<typeof useNodeStateChangeAggregator>;
const { treeWatcher, currentTreeNodes } = this;
const { treeRootRef, currentTreeNodes } = this;
const wrapper = shallowMount(
defineComponent({
setup() {
returnObject = useNodeStateChangeAggregator(treeWatcher, currentTreeNodes);
returnObject = useNodeStateChangeAggregator(treeRootRef, currentTreeNodes);
},
template: '<div></div>',
}),

View File

@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import { WatchSource, ref, nextTick } from 'vue';
import { ref, nextTick, type Ref } from 'vue';
import { CategoryNodeParser, useTreeViewNodeInput } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseTreeViewNodeInput';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub';
@@ -17,12 +17,10 @@ describe('useTreeViewNodeInput', () => {
describe('when given categoryId', () => {
it('sets input nodes correctly', async () => {
// arrange
const testCategoryId = ref<number | undefined>();
const testCategoryIdRef = ref<number | undefined>();
const {
useStateStub, returnObject, parserMock, converterMock,
} = mountWrapperComponent(
() => testCategoryId.value,
);
} = mountWrapperComponent(testCategoryIdRef);
const expectedCategoryId = 123;
const expectedCategoryCollection = new CategoryCollectionStub().withAction(
new CategoryStub(expectedCategoryId),
@@ -45,7 +43,7 @@ describe('useTreeViewNodeInput', () => {
);
// act
const { treeViewInputNodes } = returnObject;
testCategoryId.value = expectedCategoryId;
testCategoryIdRef.value = expectedCategoryId;
await nextTick();
// assert
const actualInputNodes = treeViewInputNodes.value;
@@ -60,9 +58,7 @@ describe('useTreeViewNodeInput', () => {
const testCategoryId = ref<number | undefined>();
const {
useStateStub, returnObject, parserMock, converterMock,
} = mountWrapperComponent(
() => testCategoryId.value,
);
} = mountWrapperComponent(testCategoryId);
const expectedCategoryCollection = new CategoryCollectionStub().withAction(
new CategoryStub(123),
);
@@ -92,7 +88,7 @@ describe('useTreeViewNodeInput', () => {
});
});
function mountWrapperComponent(categoryIdWatcher: WatchSource<number | undefined>) {
function mountWrapperComponent(categoryIdRef: Ref<number | undefined>) {
const useStateStub = new UseCollectionStateStub();
const parserMock = mockCategoryNodeParser();
const converterMock = mockConverter();
@@ -100,7 +96,7 @@ function mountWrapperComponent(categoryIdWatcher: WatchSource<number | undefined
shallowMount({
setup() {
returnObject = useTreeViewNodeInput(categoryIdWatcher, parserMock.mock, converterMock.mock);
returnObject = useTreeViewNodeInput(categoryIdRef, parserMock.mock, converterMock.mock);
},
template: '<div></div>',
}, {