@lukekaalim/act-recon 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/algorithms.ts +46 -0
- package/commit.ts +69 -0
- package/component.ts +24 -0
- package/context.ts +60 -0
- package/delta.test.ts +108 -0
- package/delta.ts +100 -0
- package/effects.ts +49 -0
- package/mod.ts +10 -0
- package/package.json +9 -0
- package/readme.md +3 -0
- package/reconciler.ts +36 -0
- package/state.ts +118 -0
- package/thread.ts +142 -0
- package/tree.ts +16 -0
- package/update.ts +121 -0
package/algorithms.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { MagicError } from "@lukekaalim/act";
|
|
2
|
+
|
|
3
|
+
export type ChangeReport = {
|
|
4
|
+
/** Index for Next that didnt exist in Prev */
|
|
5
|
+
created: number[],
|
|
6
|
+
/** Index for Prev that didnt exist in Next */
|
|
7
|
+
removed: number[],
|
|
8
|
+
|
|
9
|
+
/** Index for elements that are the same previously and next */
|
|
10
|
+
nextToPrev: number[],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ChangeEqualityTest<Prev, Next> = (prev: Prev, next: Next, prevIndex: number, nextIndex: number) => boolean;
|
|
14
|
+
|
|
15
|
+
export const calculateChangedElements = <Prev, Next>(
|
|
16
|
+
prevs: Prev[],
|
|
17
|
+
nexts: Next[],
|
|
18
|
+
isEqual: ChangeEqualityTest<Prev, Next>,
|
|
19
|
+
): ChangeReport => {
|
|
20
|
+
const report: ChangeReport = {
|
|
21
|
+
created: [],
|
|
22
|
+
removed: [],
|
|
23
|
+
nextToPrev: [],
|
|
24
|
+
}
|
|
25
|
+
report.nextToPrev = nexts.map((next, nextIndex) => {
|
|
26
|
+
const prevIndex = prevs.findIndex((prev, prevIndex) => isEqual(prev, next, prevIndex, nextIndex));
|
|
27
|
+
if (prevIndex === -1)
|
|
28
|
+
report.created.push(nextIndex);
|
|
29
|
+
return prevIndex;
|
|
30
|
+
});
|
|
31
|
+
report.removed = prevs
|
|
32
|
+
.map((_, index) => {
|
|
33
|
+
return report.nextToPrev.indexOf(index) !== -1 ? -1 : index;
|
|
34
|
+
})
|
|
35
|
+
.filter((index) => index !== -1)
|
|
36
|
+
|
|
37
|
+
return report;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type SortedChangeReport = ChangeReport & {
|
|
41
|
+
/** Index for elements that moved around, but were equal */
|
|
42
|
+
moved: [number, number][],
|
|
43
|
+
};
|
|
44
|
+
export const calculateSortedChangedElements = (): SortedChangeReport => {
|
|
45
|
+
throw new MagicError();
|
|
46
|
+
}
|
package/commit.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createId, Element, OpaqueID } from "@lukekaalim/act";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A single consistent id representing a commit in the act tree.
|
|
5
|
+
* Does not change.
|
|
6
|
+
*/
|
|
7
|
+
export type CommitID = OpaqueID<"CommitID">;
|
|
8
|
+
/**
|
|
9
|
+
* A array of **CommitID**'s, starting at the "root" id and "descending"
|
|
10
|
+
* until reaching (including) the subject's ID. Useful for efficiently
|
|
11
|
+
* descending the tree to find a specific change.
|
|
12
|
+
* Does not change.
|
|
13
|
+
*/
|
|
14
|
+
export type CommitPath = readonly CommitID[];
|
|
15
|
+
/**
|
|
16
|
+
* A ID for a particular _state_ a **Commit** is in - every time it or its
|
|
17
|
+
* children change, a commit with the same Id but a new CommitVersion
|
|
18
|
+
* is added to the tree, replacing the previous.
|
|
19
|
+
*/
|
|
20
|
+
export type CommitVersion = OpaqueID<"CommitVersion">;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Structure for quick lookup and identification of a commit
|
|
24
|
+
*/
|
|
25
|
+
export type CommitRef = {
|
|
26
|
+
id: CommitID;
|
|
27
|
+
path: CommitPath;
|
|
28
|
+
};
|
|
29
|
+
export const CommitRef = {
|
|
30
|
+
new(path: CommitPath = []) {
|
|
31
|
+
const id = createId<'CommitID'>();
|
|
32
|
+
return {
|
|
33
|
+
path: [...path, id],
|
|
34
|
+
id,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Representing an entry in the act "Tree"
|
|
41
|
+
*/
|
|
42
|
+
export type Commit = CommitRef & {
|
|
43
|
+
version: CommitVersion;
|
|
44
|
+
element: Element;
|
|
45
|
+
children: CommitRef[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const updateCommit = (
|
|
49
|
+
ref: CommitRef,
|
|
50
|
+
element: Element,
|
|
51
|
+
children: CommitRef[]
|
|
52
|
+
): Commit => ({
|
|
53
|
+
...ref,
|
|
54
|
+
element,
|
|
55
|
+
children,
|
|
56
|
+
version: createId(),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const Commit = {
|
|
60
|
+
new(element: Element, path: CommitPath = [], children: CommitRef[] = []): Commit {
|
|
61
|
+
return {
|
|
62
|
+
...CommitRef.new(path),
|
|
63
|
+
version: createId(),
|
|
64
|
+
children,
|
|
65
|
+
element,
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
update: updateCommit,
|
|
69
|
+
}
|
package/component.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { CommitRef } from "./commit";
|
|
2
|
+
import { ContextManager, createContextManager } from "./context"
|
|
3
|
+
import { createEffectManager, EffectManager } from "./effects"
|
|
4
|
+
import { createStateManager, StateManager } from "./state"
|
|
5
|
+
|
|
6
|
+
export type ComponentService = {
|
|
7
|
+
state: StateManager,
|
|
8
|
+
effect: EffectManager,
|
|
9
|
+
context: ContextManager,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const ComponentService = {
|
|
13
|
+
create: (requestRender: (ref: CommitRef) => unknown): ComponentService => {
|
|
14
|
+
const effect = createEffectManager();
|
|
15
|
+
const context = createContextManager();
|
|
16
|
+
const state = createStateManager(effect, requestRender, context);
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
effect,
|
|
20
|
+
context,
|
|
21
|
+
state,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
package/context.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Context, ContextID, Element, providerNodeType } from "@lukekaalim/act";
|
|
2
|
+
import { CommitID, CommitRef, Commit } from "./commit.ts";
|
|
3
|
+
import { WorkThread } from "./thread.ts";
|
|
4
|
+
|
|
5
|
+
export type ContextManager = ReturnType<typeof createContextManager>;
|
|
6
|
+
|
|
7
|
+
export type ContextState<T> = {
|
|
8
|
+
id: CommitID,
|
|
9
|
+
contextId: ContextID,
|
|
10
|
+
consumers: Map<CommitID, CommitRef>,
|
|
11
|
+
value: T,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const createContextManager = () => {
|
|
15
|
+
const contextStates = new Map<CommitID, ContextState<unknown>>();
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
/**
|
|
19
|
+
* Take an element+commit, and if a change is detected,
|
|
20
|
+
* the context value is updated and the consumers are returned.
|
|
21
|
+
*/
|
|
22
|
+
processContextElement(element: Element, commitId: CommitID) {
|
|
23
|
+
if (element.type !== providerNodeType)
|
|
24
|
+
return;
|
|
25
|
+
const prevState: ContextState<unknown> = contextStates.get(commitId) || {
|
|
26
|
+
id: commitId,
|
|
27
|
+
contextId: element.props.id as ContextID,
|
|
28
|
+
consumers: new Map(),
|
|
29
|
+
value: element.props.value,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (prevState.value !== element.props.value || !contextStates.has(commitId)) {
|
|
33
|
+
contextStates.set(commitId, { ...prevState, value: element.props.value })
|
|
34
|
+
return [...prevState.consumers.values()];
|
|
35
|
+
}
|
|
36
|
+
return [];
|
|
37
|
+
},
|
|
38
|
+
subscribeContext<T>(ref: CommitRef, context: Context<T>): T {
|
|
39
|
+
const contextsInPath = ref.path.map(id => contextStates.get(id)).reverse();
|
|
40
|
+
|
|
41
|
+
const closestContextOfType = contextsInPath.find(cState => cState && cState.contextId === context.id);
|
|
42
|
+
if (!closestContextOfType)
|
|
43
|
+
return context.defaultValue;
|
|
44
|
+
closestContextOfType.consumers.set(ref.id, ref);
|
|
45
|
+
|
|
46
|
+
return closestContextOfType.value as T;
|
|
47
|
+
},
|
|
48
|
+
unsubscribeContext(ref: CommitRef, context: Context<unknown>) {
|
|
49
|
+
const contextsInPath = ref.path.map(id => contextStates.get(id)).reverse();
|
|
50
|
+
|
|
51
|
+
const closestContextOfType = contextsInPath.find(cState => cState && cState.contextId === context.id);
|
|
52
|
+
if (!closestContextOfType)
|
|
53
|
+
return;
|
|
54
|
+
closestContextOfType.consumers.delete(ref.id);
|
|
55
|
+
},
|
|
56
|
+
deleteContextValue(ref: CommitRef) {
|
|
57
|
+
contextStates.delete(ref.id);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
};
|
package/delta.test.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { noop } from 'lodash-es';
|
|
2
|
+
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import { CommitTree } from "./tree";
|
|
5
|
+
import { WorkThread } from "./thread";
|
|
6
|
+
import { ComponentService } from "./component";
|
|
7
|
+
import { applyUpdate } from "./delta";
|
|
8
|
+
import { Update } from "./update";
|
|
9
|
+
import { Commit, CommitRef } from "./commit";
|
|
10
|
+
import { h } from "@lukekaalim/act";
|
|
11
|
+
import { equal } from "node:assert/strict";
|
|
12
|
+
|
|
13
|
+
describe('delta library', () => {
|
|
14
|
+
|
|
15
|
+
describe('generated deltas', () => {
|
|
16
|
+
it('should make a create delta if there is no prev', () => {
|
|
17
|
+
const tree = CommitTree.new();
|
|
18
|
+
const comp = ComponentService.create(noop);
|
|
19
|
+
|
|
20
|
+
const thread = WorkThread.new();
|
|
21
|
+
const element = h('test-element');
|
|
22
|
+
const ref = CommitRef.new();
|
|
23
|
+
const update = Update.fresh(ref, element);
|
|
24
|
+
|
|
25
|
+
applyUpdate(tree, comp, thread, update);
|
|
26
|
+
|
|
27
|
+
equal(thread.deltas.created.length, 1);
|
|
28
|
+
const delta = thread.deltas.created[0];
|
|
29
|
+
equal(delta.next.element, element);
|
|
30
|
+
equal(delta.next.id, ref.id);
|
|
31
|
+
equal(delta.next.path, ref.path);
|
|
32
|
+
})
|
|
33
|
+
it('should not make any deltas if the prev and next are the same (and there is no targets)', () => {
|
|
34
|
+
const tree = CommitTree.new();
|
|
35
|
+
const comp = ComponentService.create(noop);
|
|
36
|
+
|
|
37
|
+
const thread = WorkThread.new();
|
|
38
|
+
const element = h('test-element');
|
|
39
|
+
const prev = Commit.new(element);
|
|
40
|
+
const update = Update.existing(prev, element);
|
|
41
|
+
tree.commits.set(prev.id, prev);
|
|
42
|
+
|
|
43
|
+
applyUpdate(tree, comp, thread, update);
|
|
44
|
+
|
|
45
|
+
equal(thread.deltas.created.length, 0);
|
|
46
|
+
equal(thread.deltas.updated.length, 0);
|
|
47
|
+
equal(thread.deltas.removed.length, 0);
|
|
48
|
+
equal(thread.deltas.skipped.length, 0);
|
|
49
|
+
})
|
|
50
|
+
it('should make a skip delta if the prev and the next are the same, but there is a target', () => {
|
|
51
|
+
const tree = CommitTree.new();
|
|
52
|
+
const comp = ComponentService.create(noop);
|
|
53
|
+
|
|
54
|
+
const thread = WorkThread.new();
|
|
55
|
+
const element = h('test-element');
|
|
56
|
+
const prev = Commit.new(element);
|
|
57
|
+
// an imaginary child
|
|
58
|
+
const target = CommitRef.new(prev.path);
|
|
59
|
+
|
|
60
|
+
const update = Update.existing(prev, element);
|
|
61
|
+
update.targets.push(target);
|
|
62
|
+
tree.commits.set(prev.id, prev);
|
|
63
|
+
|
|
64
|
+
applyUpdate(tree, comp, thread, update);
|
|
65
|
+
|
|
66
|
+
equal(thread.deltas.skipped.length, 1);
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('should create a delta for a new id, and queue its children for creation', () => {
|
|
71
|
+
const tree = CommitTree.new();
|
|
72
|
+
const comp = ComponentService.create(noop);
|
|
73
|
+
const thread = WorkThread.new();
|
|
74
|
+
const childA = h('child-element-a');
|
|
75
|
+
const childB = h('child-element-b');
|
|
76
|
+
const element = h('new-element', {}, [childA, childB]);
|
|
77
|
+
|
|
78
|
+
const update = Update.fresh(Commit.new(element), element);
|
|
79
|
+
applyUpdate(tree, comp, thread, update);
|
|
80
|
+
|
|
81
|
+
equal(thread.deltas.created.length, 1);
|
|
82
|
+
equal(thread.pendingUpdates.length, 2);
|
|
83
|
+
|
|
84
|
+
const delta = thread.deltas.created[0];
|
|
85
|
+
const updateA = thread.pendingUpdates[0];
|
|
86
|
+
const updateB = thread.pendingUpdates[1];
|
|
87
|
+
|
|
88
|
+
equal(delta.next.element, element);
|
|
89
|
+
equal(updateA.next, childA);
|
|
90
|
+
equal(updateB.next, childB);
|
|
91
|
+
})
|
|
92
|
+
it('should create a delete delta for a node, and queue its children to be deleted', () => {
|
|
93
|
+
const tree = CommitTree.new();
|
|
94
|
+
const comp = ComponentService.create(noop);
|
|
95
|
+
const thread = WorkThread.new();
|
|
96
|
+
|
|
97
|
+
const prev = Commit.new(h('element'))
|
|
98
|
+
|
|
99
|
+
tree.commits.set(prev.id, prev);
|
|
100
|
+
|
|
101
|
+
const update = Update.remove(prev);
|
|
102
|
+
|
|
103
|
+
applyUpdate(tree, comp, thread, update);
|
|
104
|
+
|
|
105
|
+
equal(thread.deltas.removed.length, 1);
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
})
|
package/delta.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Commit, CommitID, CommitRef, updateCommit } from "./commit.ts";
|
|
2
|
+
import { WorkThread } from "./thread.ts";
|
|
3
|
+
import { Update, calculateUpdates, isDescendant } from "./update.ts";
|
|
4
|
+
import { CommitTree } from "./tree.ts";
|
|
5
|
+
import { ComponentService } from "./component.ts";
|
|
6
|
+
|
|
7
|
+
export type CreateDelta = { ref: CommitRef, next: Commit };
|
|
8
|
+
export type UpdateDelta = { ref: CommitRef, next: Commit, prev: Commit };
|
|
9
|
+
export type RemoveDelta = { ref: CommitRef, prev: Commit };
|
|
10
|
+
export type SkipDelta = { ref: CommitRef, next: Commit };
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
export type DeltaSet = {
|
|
14
|
+
created: CreateDelta[],
|
|
15
|
+
updated: UpdateDelta[],
|
|
16
|
+
skipped: SkipDelta[],
|
|
17
|
+
removed: RemoveDelta[],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Given an Update, compute if there is any
|
|
23
|
+
* change to the tree (a "Delta"), and if more updates
|
|
24
|
+
* are needed to complete this work, appending them to
|
|
25
|
+
* the work thread
|
|
26
|
+
* */
|
|
27
|
+
export const applyUpdate = (
|
|
28
|
+
tree: CommitTree,
|
|
29
|
+
comp: ComponentService,
|
|
30
|
+
thread: WorkThread,
|
|
31
|
+
{ next, prev, ref, targets }: Update
|
|
32
|
+
) => {
|
|
33
|
+
/**
|
|
34
|
+
* A change is considered identical if the "next element"
|
|
35
|
+
* is the same as the "prev element" - its the same as there
|
|
36
|
+
* being no change at all.
|
|
37
|
+
* */
|
|
38
|
+
const identicalChange = (next && prev && next.id === prev.element.id);
|
|
39
|
+
/**
|
|
40
|
+
* If we're "on a target's path", then we have to continue rendering.
|
|
41
|
+
*/
|
|
42
|
+
const requiredChange = !!targets.find(target => target.path.includes(ref.id));
|
|
43
|
+
const requiresRerender = targets.some(target => target.id === ref.id);
|
|
44
|
+
|
|
45
|
+
if (identicalChange && !requiredChange)
|
|
46
|
+
return;
|
|
47
|
+
|
|
48
|
+
const prevChildren = prev && prev.children
|
|
49
|
+
.map(c => tree.commits.get(c.id) as Commit) || [];
|
|
50
|
+
|
|
51
|
+
// If we have a "Next", then this is a request to either
|
|
52
|
+
// Create or Update a commit.
|
|
53
|
+
if (next) {
|
|
54
|
+
|
|
55
|
+
// skip the change
|
|
56
|
+
if (identicalChange && !requiresRerender) {
|
|
57
|
+
const updates = prevChildren.map(prev => ({ ref: prev, prev, next: prev.element, targets }));
|
|
58
|
+
thread.pendingUpdates.push(...updates);
|
|
59
|
+
const commit = Commit.update(ref, prev.element, prev.children);
|
|
60
|
+
thread.deltas.skipped.push({ ref, next: commit });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const contextTargets = comp.context.processContextElement(next, ref.id) || [];
|
|
65
|
+
const nextTargets = [...contextTargets, ...targets];
|
|
66
|
+
|
|
67
|
+
const childNode = comp.state.calculateCommitChildren(thread, next, ref);
|
|
68
|
+
|
|
69
|
+
const [childRefs, updates] = calculateUpdates(ref, prevChildren, childNode);
|
|
70
|
+
const finalUpdates = updates.map(update => ({
|
|
71
|
+
...update,
|
|
72
|
+
targets: nextTargets.filter(t => isDescendant(update.ref, t))
|
|
73
|
+
}))
|
|
74
|
+
|
|
75
|
+
const commit = Commit.update(ref, next, childRefs);
|
|
76
|
+
|
|
77
|
+
if (prev)
|
|
78
|
+
thread.deltas.updated.push({ ref, prev, next: commit });
|
|
79
|
+
else
|
|
80
|
+
thread.deltas.created.push({ ref, next: commit });
|
|
81
|
+
|
|
82
|
+
thread.pendingUpdates.push(...finalUpdates);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// If we have a prev, but no next, then this is a requets to
|
|
86
|
+
// delete this commit. We still have emit "delete" updates
|
|
87
|
+
// as well for all children of this node too.
|
|
88
|
+
else if (prev && !next) {
|
|
89
|
+
const [, updates] = calculateUpdates(ref, prevChildren, []);
|
|
90
|
+
comp.state.clearCommitState(thread, ref);
|
|
91
|
+
// No need to reclculate targets - no more re-rendering
|
|
92
|
+
// will happen on this set of updates.
|
|
93
|
+
|
|
94
|
+
thread.deltas.removed.push({ ref: prev, prev });
|
|
95
|
+
thread.pendingUpdates.push(...updates);
|
|
96
|
+
return;
|
|
97
|
+
} else {
|
|
98
|
+
throw new Error(`No prev, no next, did this commit ever exist?`)
|
|
99
|
+
}
|
|
100
|
+
};
|
package/effects.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { EffectCleanup, EffectConstructor, OpaqueID } from "@lukekaalim/act";
|
|
2
|
+
import { WorkThread } from "./thread.ts";
|
|
3
|
+
|
|
4
|
+
export type EffectID = OpaqueID<"EffectID">;
|
|
5
|
+
export type EffectTask = {
|
|
6
|
+
id: EffectID,
|
|
7
|
+
task: () => void,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const createEffectManager = () => {
|
|
11
|
+
const cleanups = new Map<EffectID, EffectCleanup>();
|
|
12
|
+
|
|
13
|
+
const enqueueEffect = (
|
|
14
|
+
thread: WorkThread,
|
|
15
|
+
id: EffectID,
|
|
16
|
+
effect: EffectConstructor
|
|
17
|
+
) => {
|
|
18
|
+
thread.pendingEffects.push({
|
|
19
|
+
id,
|
|
20
|
+
task() {
|
|
21
|
+
const cleanup = cleanups.get(id);
|
|
22
|
+
if (cleanup) {
|
|
23
|
+
cleanups.delete(id);
|
|
24
|
+
cleanup();
|
|
25
|
+
}
|
|
26
|
+
cleanups.set(id, effect());
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
const enqueueTeardown = (
|
|
31
|
+
thread: WorkThread,
|
|
32
|
+
id: EffectID,
|
|
33
|
+
) => {
|
|
34
|
+
thread.pendingEffects.push({
|
|
35
|
+
id,
|
|
36
|
+
task() {
|
|
37
|
+
const cleanup = cleanups.get(id);
|
|
38
|
+
if (cleanup) {
|
|
39
|
+
cleanups.delete(id);
|
|
40
|
+
cleanup();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return { enqueueEffect, enqueueTeardown };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type EffectManager = ReturnType<typeof createEffectManager>;
|
package/mod.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './commit.ts'
|
|
2
|
+
export * from './delta.ts';
|
|
3
|
+
export * from './state.ts';
|
|
4
|
+
export * from './algorithms.ts';
|
|
5
|
+
export * from './update.ts';
|
|
6
|
+
export * from './thread.ts';
|
|
7
|
+
export * from './tree.ts';
|
|
8
|
+
export * from './effects.ts';
|
|
9
|
+
|
|
10
|
+
export * from './reconciler.ts';
|
package/package.json
ADDED
package/readme.md
ADDED
package/reconciler.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
|
|
2
|
+
import { createThreadManager, WorkThread } from "./thread";
|
|
3
|
+
import { CommitTree } from "./tree";
|
|
4
|
+
import { ComponentService } from "./component";
|
|
5
|
+
import { DeltaSet } from "./delta";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A reconciler links all the relevant subsystems together.
|
|
9
|
+
*
|
|
10
|
+
* @param space
|
|
11
|
+
* @param onAfterRender
|
|
12
|
+
* @returns
|
|
13
|
+
*/
|
|
14
|
+
export const createReconciler = (
|
|
15
|
+
render: (deltas: DeltaSet) => void,
|
|
16
|
+
requestWork: () => void,
|
|
17
|
+
) => {
|
|
18
|
+
const tree = CommitTree.new();
|
|
19
|
+
const components = ComponentService.create(ref => {
|
|
20
|
+
threads.request(ref);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const threads = createThreadManager(components, tree, requestWork, (deltas, effects) => {
|
|
24
|
+
render(deltas);
|
|
25
|
+
|
|
26
|
+
// immedialty execute all side effects
|
|
27
|
+
for (const effect of effects)
|
|
28
|
+
effect.task();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
threads,
|
|
33
|
+
components,
|
|
34
|
+
tree,
|
|
35
|
+
}
|
|
36
|
+
}
|
package/state.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { CommitRef, CommitID } from "./commit.ts";
|
|
2
|
+
import { ContextManager } from "./context.ts";
|
|
3
|
+
import * as act from '@lukekaalim/act';
|
|
4
|
+
import { EffectManager, EffectID } from "./effects.ts";
|
|
5
|
+
import { WorkThread } from "./thread.ts";
|
|
6
|
+
|
|
7
|
+
export type ComponentState = {
|
|
8
|
+
ref: CommitRef;
|
|
9
|
+
|
|
10
|
+
values: Map<number, unknown>;
|
|
11
|
+
deps: Map<number, act.Deps>;
|
|
12
|
+
effects: Map<number, EffectID>;
|
|
13
|
+
contexts: Map<number, act.Context<unknown>>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const createStateManager = (
|
|
17
|
+
effectManager: EffectManager,
|
|
18
|
+
rerender: (ref: CommitRef) => unknown,
|
|
19
|
+
contextManager: ContextManager | null = null
|
|
20
|
+
) => {
|
|
21
|
+
const states = new Map<CommitID, ComponentState>();
|
|
22
|
+
|
|
23
|
+
const createState = (ref: CommitRef): ComponentState => {
|
|
24
|
+
const state = {
|
|
25
|
+
ref,
|
|
26
|
+
values: new Map(),
|
|
27
|
+
effects: new Map(),
|
|
28
|
+
deps: new Map(),
|
|
29
|
+
contexts: new Map(),
|
|
30
|
+
};
|
|
31
|
+
states.set(ref.id, state);
|
|
32
|
+
return state;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const loadHookImplementation = (thread: WorkThread, ref: CommitRef) => {
|
|
36
|
+
const createHookImplementation = (): act.HookImplementation => {
|
|
37
|
+
const state = states.get(ref.id) || createState(ref);
|
|
38
|
+
let index = 0;
|
|
39
|
+
return {
|
|
40
|
+
useContext<T>(context: act.Context<T>): T {
|
|
41
|
+
if (!contextManager)
|
|
42
|
+
throw new act.MagicError();
|
|
43
|
+
|
|
44
|
+
state.contexts.set(index, context as act.Context<unknown>);
|
|
45
|
+
return contextManager.subscribeContext(ref, context);
|
|
46
|
+
},
|
|
47
|
+
useState<T>(initialValue: act.ValueOrCalculator<T>) {
|
|
48
|
+
const stateIndex = index++;
|
|
49
|
+
if (!state.values.has(stateIndex))
|
|
50
|
+
state.values.set(stateIndex, act.calculateValue(initialValue));
|
|
51
|
+
|
|
52
|
+
const value = state.values.get(stateIndex) as T;
|
|
53
|
+
const setValue: act.StateSetter<T> = (updater) => {
|
|
54
|
+
const prevValue = state.values.get(stateIndex) as T;
|
|
55
|
+
const nextValue = act.runUpdater(prevValue, updater);
|
|
56
|
+
state.values.set(stateIndex, nextValue);
|
|
57
|
+
rerender(ref);
|
|
58
|
+
};
|
|
59
|
+
return [value, setValue];
|
|
60
|
+
},
|
|
61
|
+
useEffect(effect, deps = null) {
|
|
62
|
+
const effectIndex = index++;
|
|
63
|
+
if (!state.effects.has(effectIndex))
|
|
64
|
+
state.effects.set(effectIndex, act.createId());
|
|
65
|
+
const prevDeps = state.deps.get(effectIndex) || null;
|
|
66
|
+
const effectId = state.effects.get(effectIndex) as EffectID;
|
|
67
|
+
state.deps.set(effectIndex, deps);
|
|
68
|
+
const depsChanges = act.calculateDepsChange(prevDeps, deps)
|
|
69
|
+
if (depsChanges)
|
|
70
|
+
effectManager.enqueueEffect(thread, effectId, effect);
|
|
71
|
+
return;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const hooks = createHookImplementation();
|
|
77
|
+
act.hookImplementation.useContext = hooks.useContext;
|
|
78
|
+
act.hookImplementation.useState = hooks.useState;
|
|
79
|
+
act.hookImplementation.useEffect = hooks.useEffect;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const calculateCommitChildren = (
|
|
83
|
+
thread: WorkThread,
|
|
84
|
+
element: act.Element,
|
|
85
|
+
commit: CommitRef
|
|
86
|
+
) => {
|
|
87
|
+
const component = typeof element.type === "function" && element.type;
|
|
88
|
+
if (!component) return element.children;
|
|
89
|
+
|
|
90
|
+
const children = element.children;
|
|
91
|
+
|
|
92
|
+
const props = {
|
|
93
|
+
...element.props,
|
|
94
|
+
children,
|
|
95
|
+
} as Parameters<typeof component>[0];
|
|
96
|
+
|
|
97
|
+
loadHookImplementation(thread, commit);
|
|
98
|
+
const result = component(props);
|
|
99
|
+
|
|
100
|
+
return result;
|
|
101
|
+
};
|
|
102
|
+
const clearCommitState = (thread: WorkThread, ref: CommitRef) => {
|
|
103
|
+
const state = states.get(ref.id)
|
|
104
|
+
if (!state)
|
|
105
|
+
return;
|
|
106
|
+
const effects = [...state.effects.values()];
|
|
107
|
+
const contexts = [...state.contexts.values()];
|
|
108
|
+
for (const effect of effects)
|
|
109
|
+
effectManager.enqueueTeardown(thread, effect);
|
|
110
|
+
if (contextManager)
|
|
111
|
+
for (const context of contexts)
|
|
112
|
+
contextManager.unsubscribeContext(ref, context);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return { calculateCommitChildren, clearCommitState, states };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export type StateManager = ReturnType<typeof createStateManager>;
|
package/thread.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { convertNodeToElements, createId, Node } from "@lukekaalim/act";
|
|
2
|
+
import { Commit, CommitID, CommitRef } from "./commit.ts";
|
|
3
|
+
import { ComponentService } from "./component.ts";
|
|
4
|
+
import { applyUpdate, DeltaSet } from "./delta.ts";
|
|
5
|
+
import { EffectTask } from "./effects.ts";
|
|
6
|
+
import { CommitTree } from "./tree.ts";
|
|
7
|
+
import { Update } from "./update.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A WorkThread is a mutable data struture that
|
|
11
|
+
* represents a particular "Tree Travesal Task".
|
|
12
|
+
*
|
|
13
|
+
* Its expected when you start rendering, you
|
|
14
|
+
* may start rendering more nodes due to updates.
|
|
15
|
+
*/
|
|
16
|
+
export type WorkThread = {
|
|
17
|
+
started: boolean,
|
|
18
|
+
|
|
19
|
+
pendingUpdates: Update[],
|
|
20
|
+
pendingEffects: EffectTask[],
|
|
21
|
+
|
|
22
|
+
deltas: DeltaSet,
|
|
23
|
+
};
|
|
24
|
+
export const WorkThread = {
|
|
25
|
+
new(): WorkThread {
|
|
26
|
+
return {
|
|
27
|
+
started: false,
|
|
28
|
+
pendingEffects: [],
|
|
29
|
+
pendingUpdates: [],
|
|
30
|
+
deltas: {
|
|
31
|
+
created: [],
|
|
32
|
+
updated: [],
|
|
33
|
+
removed: [],
|
|
34
|
+
skipped: [],
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const createThreadManager = (
|
|
41
|
+
comp: ComponentService,
|
|
42
|
+
tree: CommitTree,
|
|
43
|
+
requestWork: () => void,
|
|
44
|
+
onThreadComplete: (deltas: DeltaSet, effects: EffectTask[]) => unknown = _ => {},
|
|
45
|
+
) => {
|
|
46
|
+
const pendingUpdateTargets = new Map<CommitID, CommitRef>();
|
|
47
|
+
|
|
48
|
+
let currentThread = WorkThread.new();
|
|
49
|
+
|
|
50
|
+
const run = () => {
|
|
51
|
+
const update = currentThread.pendingUpdates.pop();
|
|
52
|
+
if (update) {
|
|
53
|
+
applyUpdate(tree, comp, currentThread, update);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const work = (test: () => boolean) => {
|
|
58
|
+
let tick = 0;
|
|
59
|
+
|
|
60
|
+
while (currentThread.pendingUpdates.length > 0) {
|
|
61
|
+
run()
|
|
62
|
+
tick++;
|
|
63
|
+
|
|
64
|
+
if (currentThread.pendingUpdates.length === 0) {
|
|
65
|
+
apply();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// only test every 10 ticks
|
|
69
|
+
if ((tick % 10 === 0) && test()) {
|
|
70
|
+
// early exit
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// completed all work
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const apply = () => {
|
|
79
|
+
// Update the tree.
|
|
80
|
+
for (const delta of currentThread.deltas.created)
|
|
81
|
+
tree.commits.set(delta.ref.id, delta.next);
|
|
82
|
+
|
|
83
|
+
for (const delta of currentThread.deltas.skipped)
|
|
84
|
+
tree.commits.set(delta.ref.id, delta.next);
|
|
85
|
+
|
|
86
|
+
for (const delta of currentThread.deltas.updated)
|
|
87
|
+
tree.commits.set(delta.ref.id, delta.next);
|
|
88
|
+
|
|
89
|
+
for (const delta of currentThread.deltas.removed)
|
|
90
|
+
tree.commits.delete(delta.ref.id);
|
|
91
|
+
|
|
92
|
+
// Notify external
|
|
93
|
+
onThreadComplete(currentThread.deltas, currentThread.pendingEffects);
|
|
94
|
+
|
|
95
|
+
// clear the thread
|
|
96
|
+
currentThread = WorkThread.new();
|
|
97
|
+
|
|
98
|
+
// add any pending work that couldnt be completed last thread
|
|
99
|
+
if (pendingUpdateTargets.size > 0) {
|
|
100
|
+
const roots = CommitTree.getRootCommits(tree);
|
|
101
|
+
const targets = [...pendingUpdateTargets.values()];
|
|
102
|
+
for (const root of roots) {
|
|
103
|
+
currentThread.pendingUpdates.push(Update.distant(root, targets));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
pendingUpdateTargets.clear();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const request = (target: CommitRef) => {
|
|
111
|
+
if (currentThread.started) {
|
|
112
|
+
// TODO: add new requests to the current thread
|
|
113
|
+
// if they are compatible instead of scheduling another
|
|
114
|
+
// thread.
|
|
115
|
+
pendingUpdateTargets.set(target.id, target);
|
|
116
|
+
} else {
|
|
117
|
+
const roots = CommitTree.getRootCommits(tree);
|
|
118
|
+
for (const root of roots) {
|
|
119
|
+
currentThread.started = true;
|
|
120
|
+
currentThread.pendingUpdates.push(Update.distant(root, [target]));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
requestWork();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const mount = (root: Node) => {
|
|
128
|
+
const elements = convertNodeToElements(root);
|
|
129
|
+
for (const element of elements) {
|
|
130
|
+
const id = createId<"CommitID">();
|
|
131
|
+
const ref = { id, path: [id] };
|
|
132
|
+
tree.roots.add(ref);
|
|
133
|
+
currentThread.pendingUpdates.push(Update.fresh(ref, element));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
requestWork();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { work, request, mount }
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export type ThreadManager = ReturnType<typeof createThreadManager>;
|
package/tree.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Commit, CommitID, CommitRef } from "./commit.ts";
|
|
2
|
+
|
|
3
|
+
export type CommitTree = {
|
|
4
|
+
commits: Map<CommitID, Commit>,
|
|
5
|
+
roots: Set<CommitRef>,
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const CommitTree = {
|
|
9
|
+
new: (): CommitTree => ({
|
|
10
|
+
commits: new Map(),
|
|
11
|
+
roots: new Set(),
|
|
12
|
+
}),
|
|
13
|
+
getRootCommits: (tree: CommitTree) => {
|
|
14
|
+
return [...tree.roots].map(ref => tree.commits.get(ref.id) as Commit)
|
|
15
|
+
}
|
|
16
|
+
}
|
package/update.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { convertNodeToElements, createId, Element, Node } from "@lukekaalim/act";
|
|
2
|
+
import { calculateChangedElements, ChangeEqualityTest } from "./algorithms.ts";
|
|
3
|
+
import { Commit, CommitID, CommitRef } from "./commit.ts";
|
|
4
|
+
|
|
5
|
+
export type Update = {
|
|
6
|
+
/**
|
|
7
|
+
* The commit that should evaluate this
|
|
8
|
+
* update (if this commit does not exist,
|
|
9
|
+
* it should use this as it's ID and Path).
|
|
10
|
+
* */
|
|
11
|
+
ref: CommitRef;
|
|
12
|
+
|
|
13
|
+
/** If null, this update should cause
|
|
14
|
+
* this commit to be created */
|
|
15
|
+
prev: null | Commit;
|
|
16
|
+
/** If null, this update should cause
|
|
17
|
+
* this commit to be removed
|
|
18
|
+
*/
|
|
19
|
+
next: null | Element;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* List of commits that _must_ re-render as a
|
|
23
|
+
* concequence of this update.
|
|
24
|
+
*/
|
|
25
|
+
targets: CommitRef[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const Update = {
|
|
29
|
+
fresh: (ref: CommitRef, next: Element): Update => ({
|
|
30
|
+
ref, next, prev: null, targets: []
|
|
31
|
+
}),
|
|
32
|
+
existing: (prev: Commit, next: Element): Update => ({
|
|
33
|
+
ref: prev, next, prev, targets: [],
|
|
34
|
+
}),
|
|
35
|
+
remove: (prev: Commit): Update => ({
|
|
36
|
+
ref: prev, next: null, prev, targets: [],
|
|
37
|
+
}),
|
|
38
|
+
distant: (root: Commit, targets: CommitRef[]): Update => ({
|
|
39
|
+
ref: root, next: root.element, prev: root, targets,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create an update for a single commit and node pair.
|
|
45
|
+
*/
|
|
46
|
+
export const calculateFastUpdate = (
|
|
47
|
+
parentRef: CommitRef,
|
|
48
|
+
prevCommit: null | Commit,
|
|
49
|
+
element: Element,
|
|
50
|
+
): [CommitRef[], Update[]] => {
|
|
51
|
+
const compatible = prevCommit && prevCommit.element.type === element.type;
|
|
52
|
+
|
|
53
|
+
const updates: Update[] = [];
|
|
54
|
+
const refs: CommitRef[] = [];
|
|
55
|
+
|
|
56
|
+
if (!compatible) {
|
|
57
|
+
const id = createId<"CommitID">();
|
|
58
|
+
const path = [...parentRef.path, id];
|
|
59
|
+
const ref = { id, path };
|
|
60
|
+
updates.push(Update.fresh(ref, element));
|
|
61
|
+
|
|
62
|
+
refs.push(ref);
|
|
63
|
+
if (prevCommit)
|
|
64
|
+
updates.push(Update.remove(prevCommit));
|
|
65
|
+
} else if (prevCommit) {
|
|
66
|
+
refs.push(prevCommit);
|
|
67
|
+
updates.push(Update.existing(prevCommit, element));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return [refs, updates];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
const simpleElementEqualityTest: ChangeEqualityTest<Commit, Element> = (prev, next, prev_index, next_index) =>
|
|
75
|
+
prev.element.type === next.type && prev_index === next_index;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Returns a list of all updates that should
|
|
79
|
+
* occur -- given a set of commits and a
|
|
80
|
+
* new node that represents the next state of
|
|
81
|
+
* those commits.
|
|
82
|
+
*
|
|
83
|
+
* Also returns as part of it's tuple the next
|
|
84
|
+
* canonical list of refs, taking into account
|
|
85
|
+
* new commits and removed commits.
|
|
86
|
+
*/
|
|
87
|
+
export const calculateUpdates = (
|
|
88
|
+
parentRef: CommitRef,
|
|
89
|
+
commits: Commit[],
|
|
90
|
+
node: Node
|
|
91
|
+
): [CommitRef[], Update[]] => {
|
|
92
|
+
const elements = convertNodeToElements(node);
|
|
93
|
+
|
|
94
|
+
// Fast exit if there is only one node
|
|
95
|
+
if (commits.length <= 1 && elements.length == 1)
|
|
96
|
+
return calculateFastUpdate(parentRef, commits[0], elements[0])
|
|
97
|
+
|
|
98
|
+
const change_report = calculateChangedElements(commits, elements, simpleElementEqualityTest);
|
|
99
|
+
|
|
100
|
+
const newOrPersisted = elements.map((next, index) => {
|
|
101
|
+
const prevIndex = change_report.nextToPrev[index];
|
|
102
|
+
const prev = prevIndex !== -1 ? commits[prevIndex] : null;
|
|
103
|
+
|
|
104
|
+
if (!prev)
|
|
105
|
+
return Update.fresh(CommitRef.new(parentRef.path), next);
|
|
106
|
+
|
|
107
|
+
return Update.existing(prev, next);
|
|
108
|
+
});
|
|
109
|
+
const removed = change_report.removed.map((index) => {
|
|
110
|
+
const prev = commits[index];
|
|
111
|
+
return Update.remove(prev);
|
|
112
|
+
});
|
|
113
|
+
const updates = [...newOrPersisted, ...removed];
|
|
114
|
+
|
|
115
|
+
const refs = newOrPersisted.map((p) => p.ref);
|
|
116
|
+
return [refs, updates];
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const isDescendant = (anscestor: CommitRef, descendant: CommitRef) => {
|
|
120
|
+
return descendant.path.includes(anscestor.id);
|
|
121
|
+
}
|