@lukekaalim/act-recon 1.2.0 → 3.0.0-alpha.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/CHANGELOG.md +12 -0
- package/commit.ts +7 -2
- package/delta.ts +34 -0
- package/element.ts +8 -5
- package/event.ts +30 -0
- package/mod.ts +10 -8
- package/package.json +1 -1
- package/reconciler.ts +106 -30
- package/scheduler.ts +10 -0
- package/thread.ts +249 -233
- package/tree.ts +19 -1
- package/update.ts +11 -17
- package/work.ts +10 -0
package/CHANGELOG.md
CHANGED
package/commit.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createId, Element, OpaqueID } from "@lukekaalim/act";
|
|
2
|
-
import { version } from "os";
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* A single consistent id representing a commit in the act tree.
|
|
@@ -28,13 +27,19 @@ export type CommitRef = {
|
|
|
28
27
|
path: CommitPath;
|
|
29
28
|
};
|
|
30
29
|
export const CommitRef = {
|
|
30
|
+
from(path: CommitPath) {
|
|
31
|
+
return {
|
|
32
|
+
path,
|
|
33
|
+
id: path[path.length - 1],
|
|
34
|
+
}
|
|
35
|
+
},
|
|
31
36
|
new(path: CommitPath = []) {
|
|
32
37
|
const id = createId<'CommitID'>();
|
|
33
38
|
return {
|
|
34
39
|
path: [...path, id],
|
|
35
40
|
id,
|
|
36
41
|
}
|
|
37
|
-
}
|
|
42
|
+
},
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
/**
|
package/delta.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Commit, CommitRef } from "./commit.ts";
|
|
2
|
+
import { CommitTree } from "./tree.ts";
|
|
2
3
|
|
|
3
4
|
export type CreateDelta = { ref: CommitRef, next: Commit };
|
|
4
5
|
export type UpdateDelta = { ref: CommitRef, next: Commit, prev: Commit, moved: boolean };
|
|
@@ -11,3 +12,36 @@ export type DeltaSet = {
|
|
|
11
12
|
skipped: SkipDelta[],
|
|
12
13
|
removed: RemoveDelta[],
|
|
13
14
|
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Apply a deltaset to a tree, modifying it's commit list
|
|
18
|
+
* to match the changes produced by the thread.
|
|
19
|
+
*
|
|
20
|
+
* @param thread
|
|
21
|
+
* @param tree
|
|
22
|
+
*/
|
|
23
|
+
const applyDeltaSet = (deltas: DeltaSet, tree: CommitTree) => {
|
|
24
|
+
for (const delta of deltas.created)
|
|
25
|
+
tree.commits.set(delta.ref.id, delta.next);
|
|
26
|
+
|
|
27
|
+
for (const delta of deltas.skipped)
|
|
28
|
+
tree.commits.set(delta.next.id, delta.next);
|
|
29
|
+
|
|
30
|
+
for (const delta of deltas.updated)
|
|
31
|
+
tree.commits.set(delta.ref.id, delta.next);
|
|
32
|
+
|
|
33
|
+
for (const delta of deltas.removed)
|
|
34
|
+
tree.commits.delete(delta.ref.id);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const DeltaSet = {
|
|
38
|
+
create: (): DeltaSet => ({ created: [], updated: [], skipped: [], removed: [] }),
|
|
39
|
+
clone: (deltas: DeltaSet): DeltaSet => ({
|
|
40
|
+
created: [...deltas.created],
|
|
41
|
+
updated: [...deltas.updated],
|
|
42
|
+
skipped: [...deltas.skipped],
|
|
43
|
+
removed: [...deltas.removed],
|
|
44
|
+
}),
|
|
45
|
+
apply: applyDeltaSet,
|
|
46
|
+
}
|
|
47
|
+
|
package/element.ts
CHANGED
|
@@ -40,7 +40,6 @@ export const createElementService = (
|
|
|
40
40
|
requestRender: (ref: CommitRef) => void
|
|
41
41
|
): ElementService => {
|
|
42
42
|
const contextStates = new Map<CommitID, ContextState<unknown>>();
|
|
43
|
-
const componentStates = new Map<CommitID, ComponentState>();
|
|
44
43
|
const boundaryValues = new Map<CommitID, unknown>();
|
|
45
44
|
|
|
46
45
|
const render = (
|
|
@@ -82,7 +81,7 @@ export const createElementService = (
|
|
|
82
81
|
}
|
|
83
82
|
break;
|
|
84
83
|
case 'function': {
|
|
85
|
-
let state =
|
|
84
|
+
let state = tree.components.get(ref.id);
|
|
86
85
|
if (!state) {
|
|
87
86
|
state = {
|
|
88
87
|
unmounted: false,
|
|
@@ -93,7 +92,7 @@ export const createElementService = (
|
|
|
93
92
|
deps: new Map(),
|
|
94
93
|
effects: new Map(),
|
|
95
94
|
}
|
|
96
|
-
|
|
95
|
+
tree.components.set(ref.id, state);
|
|
97
96
|
}
|
|
98
97
|
loadHooks(contextStates, requestRender, state, ref, output);
|
|
99
98
|
const props = {
|
|
@@ -125,7 +124,7 @@ export const createElementService = (
|
|
|
125
124
|
break;
|
|
126
125
|
}
|
|
127
126
|
case 'function': {
|
|
128
|
-
const componentState =
|
|
127
|
+
const componentState = tree.components.get(prev.id) as ComponentState;
|
|
129
128
|
componentState.unmounted = true;
|
|
130
129
|
for (const [,context] of componentState.contexts) {
|
|
131
130
|
if (context.state)
|
|
@@ -143,7 +142,7 @@ export const createElementService = (
|
|
|
143
142
|
}
|
|
144
143
|
});
|
|
145
144
|
}
|
|
146
|
-
|
|
145
|
+
tree.components.delete(prev.id);
|
|
147
146
|
break;
|
|
148
147
|
}
|
|
149
148
|
}
|
|
@@ -153,3 +152,7 @@ export const createElementService = (
|
|
|
153
152
|
|
|
154
153
|
return { render, clear, boundary: boundaryValues };
|
|
155
154
|
}
|
|
155
|
+
|
|
156
|
+
export const ElementService = {
|
|
157
|
+
create: createElementService
|
|
158
|
+
}
|
package/event.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type Subscription = { cancel: () => void };
|
|
2
|
+
export type EventHandler<T> = (event: T) => unknown;
|
|
3
|
+
|
|
4
|
+
export type EventEmitter<T> = {
|
|
5
|
+
subscribe(handler: EventHandler<T>): Subscription,
|
|
6
|
+
emit(event: T): void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const createEventEmitter = <T>(): EventEmitter<T> => {
|
|
10
|
+
const handlers = new Map<number, EventHandler<T>>();
|
|
11
|
+
let counter = 0;
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
subscribe(handler) {
|
|
15
|
+
const id = counter++;
|
|
16
|
+
handlers.set(id, handler);
|
|
17
|
+
return {
|
|
18
|
+
cancel() {
|
|
19
|
+
handlers.delete(id);
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
emit(event) {
|
|
24
|
+
for (const handler of handlers.values())
|
|
25
|
+
try {
|
|
26
|
+
handler(event);
|
|
27
|
+
} finally {}
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
};
|
package/mod.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
export * from './commit
|
|
2
|
-
export * from './delta
|
|
3
|
-
export * from './state
|
|
4
|
-
export * from './algorithms
|
|
5
|
-
export * from './
|
|
6
|
-
export * from './
|
|
7
|
-
export * from './
|
|
1
|
+
export * from './commit'
|
|
2
|
+
export * from './delta';
|
|
3
|
+
export * from './state';
|
|
4
|
+
export * from './algorithms';
|
|
5
|
+
export * from './scheduler';
|
|
6
|
+
export * from './update';
|
|
7
|
+
export * from './thread';
|
|
8
|
+
export * from './tree';
|
|
9
|
+
export * from './event';
|
|
8
10
|
|
|
9
|
-
export * from './reconciler
|
|
11
|
+
export * from './reconciler';
|
package/package.json
CHANGED
package/reconciler.ts
CHANGED
|
@@ -1,36 +1,112 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { convertNodeToElements, Node, OpaqueID } from "@lukekaalim/act";
|
|
2
|
+
import { CommitID, CommitRef } from "./commit";
|
|
3
|
+
import { WorkThread } from "./thread"
|
|
2
4
|
import { CommitTree } from "./tree";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
5
|
+
import { ElementService } from "./element";
|
|
6
|
+
import { Scheduler, WorkID } from "./scheduler";
|
|
7
|
+
import { createEventEmitter, EventEmitter } from "./event";
|
|
8
|
+
|
|
9
|
+
export type Reconciler = {
|
|
10
|
+
mount(node: Node): void,
|
|
11
|
+
render(ref: CommitRef): void,
|
|
12
|
+
|
|
13
|
+
state: ReconcilerState,
|
|
14
|
+
tree: CommitTree,
|
|
15
|
+
elements: ElementService,
|
|
16
|
+
subscribe: EventEmitter<ReconcilerEvent>["subscribe"],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ReconcilerState = {
|
|
20
|
+
thread: WorkThread | null,
|
|
21
|
+
work: WorkID | null,
|
|
22
|
+
/**
|
|
23
|
+
* These are targets that can't be fulfilled with the current thread
|
|
24
|
+
* */
|
|
25
|
+
pendingTargets: Map<CommitID, CommitRef>,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ReconcilerEvent =
|
|
29
|
+
| { type: 'thread:start', thread: WorkThread }
|
|
30
|
+
| { type: 'thread:update', thread: WorkThread }
|
|
31
|
+
| { type: 'thread:complete', thread: WorkThread }
|
|
32
|
+
| { type: 'thread:new-target', thread: WorkThread }
|
|
33
|
+
| { type: 'thread:new-root', thread: WorkThread }
|
|
34
|
+
|
|
35
|
+
export const createReconciler = (scheduler: Scheduler): Reconciler => {
|
|
36
|
+
const events = createEventEmitter<ReconcilerEvent>();
|
|
37
|
+
const state: ReconcilerState = {
|
|
38
|
+
thread: null,
|
|
39
|
+
work: null,
|
|
40
|
+
pendingTargets: new Map(),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const work = () => {
|
|
44
|
+
state.work = null;
|
|
45
|
+
if (!state.thread)
|
|
46
|
+
return;
|
|
47
|
+
|
|
48
|
+
const update = state.thread.pendingUpdates.pop();
|
|
49
|
+
if (update) {
|
|
50
|
+
WorkThread.update(state.thread, update, tree, elements);
|
|
51
|
+
state.work = scheduler.requestWork(work);
|
|
52
|
+
} else {
|
|
53
|
+
const completedThread = state.thread;
|
|
54
|
+
state.thread = null;
|
|
55
|
+
|
|
56
|
+
const pendingTargets = [...state.pendingTargets]
|
|
57
|
+
state.pendingTargets.clear();
|
|
58
|
+
|
|
59
|
+
for (const [,target] of pendingTargets)
|
|
60
|
+
render(target);
|
|
61
|
+
|
|
62
|
+
WorkThread.apply(completedThread, tree);
|
|
63
|
+
events.emit({ type: 'thread:complete', thread: completedThread });
|
|
64
|
+
|
|
65
|
+
// Run side effects
|
|
66
|
+
for (const effect of completedThread.pendingEffects) {
|
|
67
|
+
try {
|
|
68
|
+
effect.func();
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error(error);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const start = () => {
|
|
77
|
+
if (!state.thread) {
|
|
78
|
+
state.thread = WorkThread.new();
|
|
79
|
+
events.emit({ type: 'thread:start', thread: state.thread });
|
|
80
|
+
}
|
|
81
|
+
if (!state.work) {
|
|
82
|
+
state.work = scheduler.requestWork(work);
|
|
83
|
+
}
|
|
84
|
+
return state.thread;
|
|
27
85
|
}
|
|
28
86
|
|
|
29
|
-
const
|
|
87
|
+
const mount = (node: Node) => {
|
|
88
|
+
const thread = start();
|
|
89
|
+
const elements = convertNodeToElements(node)
|
|
30
90
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
91
|
+
for (const element of elements) {
|
|
92
|
+
const ref = CommitRef.new()
|
|
93
|
+
tree.roots.add(ref);
|
|
94
|
+
WorkThread.queueMount(thread, ref, element);
|
|
95
|
+
}
|
|
96
|
+
events.emit({ type: 'thread:new-root', thread });
|
|
97
|
+
};
|
|
98
|
+
const render = (ref: CommitRef) => {
|
|
99
|
+
const thread = start();
|
|
100
|
+
|
|
101
|
+
if (WorkThread.queueTarget(thread, ref, tree)) {
|
|
102
|
+
events.emit({ type: 'thread:new-target', thread });
|
|
103
|
+
} else {
|
|
104
|
+
state.pendingTargets.set(ref.id, ref);
|
|
105
|
+
}
|
|
35
106
|
}
|
|
107
|
+
|
|
108
|
+
const tree = CommitTree.new();
|
|
109
|
+
const elements = ElementService.create(tree, render);
|
|
110
|
+
|
|
111
|
+
return { mount, render, state, tree, elements, subscribe: events.subscribe };
|
|
36
112
|
}
|
package/scheduler.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { OpaqueID } from "@lukekaalim/act";
|
|
2
|
+
|
|
3
|
+
export type WorkID = OpaqueID<"WorkID">;
|
|
4
|
+
/**
|
|
5
|
+
* Work provider is platform agnostic timer/callback interface
|
|
6
|
+
*/
|
|
7
|
+
export type Scheduler = {
|
|
8
|
+
requestWork(callback: () => void): WorkID,
|
|
9
|
+
cancelWork(workId: WorkID): void,
|
|
10
|
+
};
|
package/thread.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { convertNodeToElements, createId, ErrorBoundaryProps, errorBoundaryType, Node } from "@lukekaalim/act";
|
|
1
|
+
import { convertNodeToElements, createId, Element, ErrorBoundaryProps, errorBoundaryType, Node } from "@lukekaalim/act";
|
|
2
2
|
import { Commit, CommitID, CommitRef } from "./commit.ts";
|
|
3
3
|
import { DeltaSet } from "./delta.ts";
|
|
4
4
|
import { CommitTree } from "./tree.ts";
|
|
@@ -8,277 +8,293 @@ import { EffectTask } from "./state.ts";
|
|
|
8
8
|
import { ErrorBoundaryState } from "./errors.ts";
|
|
9
9
|
import { first, last } from "./algorithms.ts";
|
|
10
10
|
|
|
11
|
+
export type WorkReason =
|
|
12
|
+
| { type: 'mount', element: Element, ref: CommitRef }
|
|
13
|
+
| { type: 'target', ref: CommitRef }
|
|
14
|
+
|
|
11
15
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
16
|
+
* Apply a thread to a tree, modifying it's commit list
|
|
17
|
+
* to match the changes produced by the thread.
|
|
14
18
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
19
|
+
* @param thread
|
|
20
|
+
* @param tree
|
|
17
21
|
*/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
pendingEffects: EffectTask[],
|
|
23
|
-
|
|
24
|
-
errorNotifications: Set<CommitID>,
|
|
22
|
+
const applyWorkThread = (thread: WorkThread, tree: CommitTree) => {
|
|
23
|
+
DeltaSet.apply(thread.deltas, tree);
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
pendingEffects: [],
|
|
34
|
-
pendingUpdates: [],
|
|
35
|
-
errorNotifications: new Set(),
|
|
36
|
-
visited: new Set(),
|
|
37
|
-
deltas: {
|
|
38
|
-
created: [],
|
|
39
|
-
updated: [],
|
|
40
|
-
removed: [],
|
|
41
|
-
skipped: [],
|
|
42
|
-
},
|
|
27
|
+
const notifyErrorBoundaries = (thread: WorkThread, tree: CommitTree) => {
|
|
28
|
+
for (const [boundaryId] of thread.errorNotifications) {
|
|
29
|
+
const commit = tree.commits.get(boundaryId) as Commit;
|
|
30
|
+
const { onError, ref } = commit.element.props as ErrorBoundaryProps;
|
|
31
|
+
if (typeof onError === 'function') {
|
|
32
|
+
const state = CommitTree.getError(tree, commit.id);
|
|
33
|
+
onError(state.value);
|
|
43
34
|
}
|
|
44
|
-
},
|
|
45
|
-
/**
|
|
46
|
-
* Remove all changes from a particular commit onward
|
|
47
|
-
*/
|
|
48
|
-
rollback(thread: WorkThread, from: CommitRef) {
|
|
49
|
-
thread.deltas.created = thread.deltas.created.filter(d => !isDescendant(from, d.ref));
|
|
50
|
-
thread.deltas.updated = thread.deltas.updated.filter(d => !isDescendant(from, d.ref));
|
|
51
|
-
thread.deltas.removed = thread.deltas.removed.filter(d => !isDescendant(from, d.ref));
|
|
52
|
-
thread.deltas.skipped = thread.deltas.skipped.filter(d => !isDescendant(from, d.next));
|
|
53
|
-
thread.pendingUpdates = thread.pendingUpdates.filter(update => !isDescendant(from, update.ref))
|
|
54
|
-
thread.pendingEffects = thread.pendingEffects.filter(effect => !isDescendant(from, effect.ref))
|
|
55
|
-
},
|
|
56
|
-
notifyError(thread: WorkThread, ref: CommitRef) {
|
|
57
|
-
thread.errorNotifications.add(ref.id);
|
|
58
|
-
},
|
|
59
|
-
/**
|
|
60
|
-
* Find the closest anscestor error boundary for a commit,
|
|
61
|
-
* either in the tree or one that was just created
|
|
62
|
-
* */
|
|
63
|
-
findClosestBoundary(thread: WorkThread, tree: CommitTree, ref: CommitRef): Commit | null {
|
|
64
|
-
return last(ref.path, id => {
|
|
65
|
-
if (tree.commits.has(id)) {
|
|
66
|
-
const commit = tree.commits.get(id) as Commit;
|
|
67
|
-
if (commit.element.type === errorBoundaryType)
|
|
68
|
-
return commit;
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
// We also might have just created the boundary
|
|
72
|
-
const freshBoundary = thread.deltas.created.find(c => c.ref.id === id && c.next.element.type === errorBoundaryType);
|
|
73
|
-
if (freshBoundary)
|
|
74
|
-
return freshBoundary.next;
|
|
75
|
-
return null;
|
|
76
|
-
});
|
|
77
35
|
}
|
|
78
36
|
}
|
|
79
37
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Remove all changes from a particular commit and all it's children
|
|
40
|
+
* - essentially making it as if it had never rendered at all
|
|
41
|
+
* @param thread
|
|
42
|
+
* @param from
|
|
43
|
+
*/
|
|
44
|
+
const rollbackWorkThread = (thread: WorkThread, from: CommitRef) => {
|
|
45
|
+
// TODO: rollbacks are hard: need rollback compatible with new
|
|
46
|
+
// thread model
|
|
47
|
+
throw new Error();
|
|
48
|
+
/*
|
|
49
|
+
thread.deltas.created = thread.deltas.created.filter(d => !isDescendant(from, d.ref));
|
|
50
|
+
thread.deltas.updated = thread.deltas.updated.filter(d => !isDescendant(from, d.ref));
|
|
51
|
+
thread.deltas.removed = thread.deltas.removed.filter(d => !isDescendant(from, d.ref));
|
|
52
|
+
thread.deltas.skipped = thread.deltas.skipped.filter(d => !isDescendant(from, d.next));
|
|
87
53
|
|
|
88
|
-
|
|
54
|
+
thread.visited = new Set([...thread.visited].filter(v => !isDescendant(from, v)))
|
|
89
55
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
}
|
|
56
|
+
thread.pendingUpdates = thread.pendingUpdates.filter(update => !isDescendant(from, update.ref))
|
|
57
|
+
thread.pendingEffects = thread.pendingEffects.filter(effect => !isDescendant(from, effect.ref))
|
|
58
|
+
*/
|
|
59
|
+
}
|
|
96
60
|
|
|
97
|
-
|
|
98
|
-
|
|
61
|
+
const updateWorkThread = (thread: WorkThread, update: Update, tree: CommitTree, element: ElementService) => {
|
|
62
|
+
const { next, prev, ref, moved } = update;
|
|
99
63
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
64
|
+
const identicalChange = next && prev && (next.id === prev.element.id);
|
|
65
|
+
const prevChildren = prev && prev.children
|
|
66
|
+
.map(c => tree.commits.get(c.id) as Commit) || [];
|
|
103
67
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
68
|
+
if (identicalChange) {
|
|
69
|
+
const mustVisit = thread.mustVisit.has(ref.id);
|
|
70
|
+
if (!mustVisit)
|
|
71
|
+
return;
|
|
107
72
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
73
|
+
const mustRender = thread.mustRender.has(ref.id);
|
|
74
|
+
|
|
75
|
+
if (!mustRender) {
|
|
76
|
+
const updates = prevChildren.map(prev => Update.skip(prev));
|
|
77
|
+
thread.pendingUpdates.push(...updates);
|
|
78
|
+
const commit = Commit.version(prev);
|
|
79
|
+
thread.deltas.skipped.push({ next: commit });
|
|
80
|
+
return;
|
|
113
81
|
}
|
|
114
|
-
// completed all work
|
|
115
|
-
return true;
|
|
116
82
|
}
|
|
83
|
+
thread.visited.set(ref.id, ref);
|
|
84
|
+
|
|
85
|
+
if (next) {
|
|
86
|
+
const output = element.render(next, ref);
|
|
87
|
+
if (output.reject) {
|
|
88
|
+
const errorBoundary = WorkThread.findClosestBoundary(thread, tree, ref);
|
|
89
|
+
if (errorBoundary) {
|
|
90
|
+
const errorState = CommitTree.getError(tree, errorBoundary.id);
|
|
91
|
+
ErrorBoundaryState.set(errorState, output.reject);
|
|
92
|
+
WorkThread.rollback(thread, errorBoundary);
|
|
93
|
+
WorkThread.notifyError(thread, errorBoundary);
|
|
117
94
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
for (const delta of currentThread.deltas.removed)
|
|
130
|
-
tree.commits.delete(delta.ref.id);
|
|
131
|
-
|
|
132
|
-
for (const boundaryId of currentThread.errorNotifications) {
|
|
133
|
-
const commit = tree.commits.get(boundaryId) as Commit;
|
|
134
|
-
const { onError } = commit.element.props as ErrorBoundaryProps;
|
|
135
|
-
if (typeof onError === 'function') {
|
|
136
|
-
const state = CommitTree.getError(tree, commit.id);
|
|
137
|
-
const clear = () => {
|
|
138
|
-
ErrorBoundaryState.clear(state);
|
|
139
|
-
request(commit);
|
|
95
|
+
thread.pendingUpdates.push(Update.target(errorBoundary));
|
|
96
|
+
return;
|
|
97
|
+
} else {
|
|
98
|
+
console.error(output.reject);
|
|
99
|
+
console.error(`No boundary to catch error: Unmounting roots`);
|
|
100
|
+
for (const ref of tree.roots) {
|
|
101
|
+
WorkThread.rollback(thread, ref);
|
|
102
|
+
const prev = tree.commits.get(ref.id);
|
|
103
|
+
if (prev)
|
|
104
|
+
thread.pendingUpdates.push(Update.remove(prev));
|
|
140
105
|
}
|
|
141
|
-
onError(state.value, clear);
|
|
142
106
|
}
|
|
143
107
|
}
|
|
144
108
|
|
|
145
|
-
|
|
146
|
-
onThreadComplete(currentThread.deltas, currentThread.pendingEffects);
|
|
109
|
+
const [childRefs, updates] = calculateUpdates(ref, prevChildren, output.child);
|
|
147
110
|
|
|
148
|
-
|
|
149
|
-
|
|
111
|
+
thread.pendingEffects.push(...output.effects);
|
|
112
|
+
thread.pendingUpdates.push(...updates);
|
|
150
113
|
|
|
151
|
-
|
|
152
|
-
if (pendingUpdateTargets.size > 0) {
|
|
153
|
-
const roots = CommitTree.getRootCommits(tree);
|
|
154
|
-
const targets = [...pendingUpdateTargets.values()];
|
|
155
|
-
for (const root of roots) {
|
|
156
|
-
currentThread.pendingUpdates.push(Update.distant(root, targets));
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
pendingUpdateTargets.clear();
|
|
160
|
-
currentThread.started = true;
|
|
161
|
-
requestWork();
|
|
162
|
-
}
|
|
163
|
-
}
|
|
114
|
+
const commit = Commit.update(ref, next, childRefs);
|
|
164
115
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
})
|
|
170
|
-
if (updateOnPath) {
|
|
171
|
-
if (updateOnPath.targets.some(target => target.id === target.id)) {
|
|
172
|
-
// not sure whats going on here
|
|
173
|
-
} else {
|
|
174
|
-
updateOnPath.targets.push(target);
|
|
175
|
-
}
|
|
176
|
-
} else {
|
|
177
|
-
pendingUpdateTargets.set(target.id, target);
|
|
178
|
-
}
|
|
179
|
-
} else {
|
|
180
|
-
const roots = CommitTree.getRootCommits(tree);
|
|
181
|
-
for (const root of roots) {
|
|
182
|
-
currentThread.started = true;
|
|
183
|
-
currentThread.pendingUpdates.push(Update.distant(root, [target]));
|
|
184
|
-
}
|
|
185
|
-
}
|
|
116
|
+
if (prev)
|
|
117
|
+
thread.deltas.updated.push({ ref, prev, next: commit, moved });
|
|
118
|
+
else
|
|
119
|
+
thread.deltas.created.push({ ref, next: commit });
|
|
186
120
|
|
|
187
|
-
|
|
188
|
-
|
|
121
|
+
// Update tree
|
|
122
|
+
//tree.commits.set(ref.id, commit);
|
|
189
123
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
tree.roots.add(ref);
|
|
196
|
-
currentThread.pendingUpdates.push(Update.fresh(ref, element));
|
|
197
|
-
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
else if (prev && !next) {
|
|
127
|
+
// We should delay this?
|
|
128
|
+
const output = element.clear(prev);
|
|
198
129
|
|
|
199
|
-
|
|
130
|
+
thread.deltas.removed.push({ ref: prev, prev });
|
|
131
|
+
thread.pendingUpdates.push(...prevChildren.map(prev => Update.remove(prev)));
|
|
132
|
+
thread.pendingEffects.push(...output.effects);
|
|
133
|
+
return;
|
|
134
|
+
} else {
|
|
135
|
+
throw new Error(`No prev, no next, did this commit ever exist?`)
|
|
200
136
|
}
|
|
137
|
+
};
|
|
201
138
|
|
|
202
|
-
|
|
203
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Start a new Update in the current Thread.
|
|
141
|
+
* @param thread
|
|
142
|
+
* @param ref
|
|
143
|
+
* @param tree
|
|
144
|
+
*/
|
|
145
|
+
const startWorkThreadUpdate = (thread: WorkThread, ref: CommitRef, prev: Commit | null, next: Element | null) => {
|
|
146
|
+
// Once a update starts,
|
|
147
|
+
// all "parents" are considered to have been visited,
|
|
148
|
+
// and cannot be rendered in this pass.
|
|
149
|
+
for (const id of [...ref.path].reverse().slice(1))
|
|
150
|
+
thread.visited.set(id, ref);
|
|
204
151
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
152
|
+
thread.pendingUpdates.push({
|
|
153
|
+
ref,
|
|
154
|
+
prev,
|
|
155
|
+
next,
|
|
156
|
+
moved: false,
|
|
157
|
+
})
|
|
158
|
+
}
|
|
208
159
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Request that a commit be re-rendered
|
|
162
|
+
*
|
|
163
|
+
* If returns false, the update cannot be queued in the current
|
|
164
|
+
* thread (maybe it already re-rendered?).
|
|
165
|
+
* @param thread
|
|
166
|
+
* @param ref
|
|
167
|
+
* @returns
|
|
168
|
+
*/
|
|
169
|
+
const queueWorkThreadTarget = (thread: WorkThread, ref: CommitRef, tree: CommitTree): boolean => {
|
|
170
|
+
// If the thread _already_ has this ref as a target,
|
|
171
|
+
// do nothing
|
|
172
|
+
if (thread.mustRender.has(ref.id))
|
|
173
|
+
return true;
|
|
213
174
|
|
|
214
|
-
|
|
175
|
+
// We cant do work on a commit that has
|
|
176
|
+
// already been visited
|
|
177
|
+
if (thread.visited.has(ref.id))
|
|
178
|
+
return false;
|
|
215
179
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
180
|
+
thread.reasons.push({ type: 'target', ref });
|
|
181
|
+
thread.mustRender.set(ref.id, ref);
|
|
182
|
+
|
|
183
|
+
// Search through all the parents, looking to see if
|
|
184
|
+
// there are any pendingUpdates that might
|
|
185
|
+
// lead to this commit. If so, make sure ancestor commit
|
|
186
|
+
// is on the MustVisit so they should make their way down
|
|
187
|
+
// eventually
|
|
188
|
+
for (const id of [...ref.path].reverse().slice(1)) {
|
|
189
|
+
thread.mustVisit.set(ref.id, ref);
|
|
190
|
+
|
|
191
|
+
for (const update of thread.pendingUpdates) {
|
|
192
|
+
// Found an ancestor pending update - it should
|
|
193
|
+
// handle our target eventually
|
|
194
|
+
if (update.ref.id === id)
|
|
195
|
+
return true;
|
|
224
196
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
thread.pendingUpdates.push(Update.target(errorBoundary));
|
|
236
|
-
return;
|
|
237
|
-
} else {
|
|
238
|
-
console.error(output.reject);
|
|
239
|
-
console.error(`No boundary to catch error: Unmounting roots`);
|
|
240
|
-
for (const ref of tree.roots) {
|
|
241
|
-
WorkThread.rollback(thread, ref);
|
|
242
|
-
const prev = tree.commits.get(ref.id);
|
|
243
|
-
if (prev)
|
|
244
|
-
thread.pendingUpdates.push(Update.remove(prev));
|
|
245
|
-
}
|
|
197
|
+
}
|
|
198
|
+
// otherwise, start a new update from the root
|
|
199
|
+
const prev = tree.commits.get(ref.id) as Commit;
|
|
200
|
+
startWorkThreadUpdate(thread, ref, prev, prev.element);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
const queueWorkThreadMount = (thread: WorkThread, ref: CommitRef, element: Element) => {
|
|
204
|
+
thread.reasons.push({ type: 'mount', element, ref });
|
|
205
|
+
startWorkThreadUpdate(thread, ref, null, element);
|
|
206
|
+
};
|
|
246
207
|
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const [childRefs, updates] = calculateUpdates(ref, prevChildren, output.child);
|
|
251
208
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
209
|
+
/**
|
|
210
|
+
* A WorkThread is a mutable data structure that
|
|
211
|
+
* represents a particular "Tree Traversal Task".
|
|
212
|
+
*
|
|
213
|
+
* Its expected when you start rendering, you
|
|
214
|
+
* may start rendering more nodes due to updates.
|
|
215
|
+
*
|
|
216
|
+
* A thread can be "worked" to remove an update off the
|
|
217
|
+
* "pending updates" list, which may optionally produce more
|
|
218
|
+
* updates, effects, or error notification.
|
|
219
|
+
*/
|
|
220
|
+
export type WorkThread = {
|
|
221
|
+
reasons: WorkReason[],
|
|
257
222
|
|
|
258
|
-
|
|
223
|
+
mustRender: Map<CommitID, CommitRef>,
|
|
224
|
+
mustVisit: Map<CommitID, CommitRef>,
|
|
259
225
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
else
|
|
263
|
-
thread.deltas.created.push({ ref, next: commit });
|
|
226
|
+
pendingUpdates: Update[],
|
|
227
|
+
pendingEffects: EffectTask[],
|
|
264
228
|
|
|
265
|
-
|
|
266
|
-
//tree.commits.set(ref.id, commit);
|
|
267
|
-
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
else if (prev && !next) {
|
|
271
|
-
const output = elementService.clear(prev);
|
|
272
|
-
thread.deltas.removed.push({ ref: prev, prev });
|
|
273
|
-
thread.pendingUpdates.push(...prevChildren.map(prev => Update.remove(prev)));
|
|
274
|
-
thread.pendingEffects.push(...output.effects);
|
|
275
|
-
return;
|
|
276
|
-
} else {
|
|
277
|
-
throw new Error(`No prev, no next, did this commit ever exist?`)
|
|
278
|
-
}
|
|
279
|
-
};
|
|
229
|
+
errorNotifications: Map<CommitID, CommitRef>,
|
|
280
230
|
|
|
281
|
-
|
|
231
|
+
/**
|
|
232
|
+
* A list of each commit the thread processed
|
|
233
|
+
*/
|
|
234
|
+
visited: Map<CommitID, CommitRef>,
|
|
235
|
+
deltas: DeltaSet,
|
|
282
236
|
};
|
|
283
237
|
|
|
284
|
-
export
|
|
238
|
+
export const cloneWorkThread = (thread: WorkThread): WorkThread => {
|
|
239
|
+
return {
|
|
240
|
+
reasons: [...thread.reasons],
|
|
241
|
+
pendingEffects: [...thread.pendingEffects],
|
|
242
|
+
pendingUpdates: [...thread.pendingUpdates],
|
|
243
|
+
errorNotifications: new Map(thread.errorNotifications),
|
|
244
|
+
|
|
245
|
+
mustVisit: new Map(thread.mustVisit),
|
|
246
|
+
mustRender: new Map(thread.mustRender),
|
|
247
|
+
|
|
248
|
+
visited: new Map(thread.visited),
|
|
249
|
+
|
|
250
|
+
deltas: DeltaSet.clone(thread.deltas),
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export const WorkThread = {
|
|
255
|
+
new(): WorkThread {
|
|
256
|
+
return {
|
|
257
|
+
reasons: [],
|
|
258
|
+
pendingEffects: [],
|
|
259
|
+
pendingUpdates: [],
|
|
260
|
+
errorNotifications: new Map(),
|
|
261
|
+
|
|
262
|
+
mustVisit: new Map(),
|
|
263
|
+
mustRender: new Map(),
|
|
264
|
+
|
|
265
|
+
visited: new Map(),
|
|
266
|
+
|
|
267
|
+
deltas: DeltaSet.create(),
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
rollback: rollbackWorkThread,
|
|
271
|
+
apply: applyWorkThread,
|
|
272
|
+
update: updateWorkThread,
|
|
273
|
+
queueTarget: queueWorkThreadTarget,
|
|
274
|
+
queueMount: queueWorkThreadMount,
|
|
275
|
+
|
|
276
|
+
clone: cloneWorkThread,
|
|
277
|
+
|
|
278
|
+
notifyError(thread: WorkThread, ref: CommitRef) {
|
|
279
|
+
thread.errorNotifications.set(ref.id, ref);
|
|
280
|
+
},
|
|
281
|
+
/**
|
|
282
|
+
* Find the closest ancestor error boundary for a commit,
|
|
283
|
+
* either in the tree or one that was just created
|
|
284
|
+
* */
|
|
285
|
+
findClosestBoundary(thread: WorkThread, tree: CommitTree, ref: CommitRef): Commit | null {
|
|
286
|
+
return last(ref.path, id => {
|
|
287
|
+
if (tree.commits.has(id)) {
|
|
288
|
+
const commit = tree.commits.get(id) as Commit;
|
|
289
|
+
if (commit.element.type === errorBoundaryType)
|
|
290
|
+
return commit;
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
// We also might have just created the boundary
|
|
294
|
+
const freshBoundary = thread.deltas.created.find(c => c.ref.id === id && c.next.element.type === errorBoundaryType);
|
|
295
|
+
if (freshBoundary)
|
|
296
|
+
return freshBoundary.next;
|
|
297
|
+
return null;
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
package/tree.ts
CHANGED
|
@@ -21,9 +21,27 @@ export const CommitTree = {
|
|
|
21
21
|
commits: new Map(),
|
|
22
22
|
roots: new Set(),
|
|
23
23
|
}),
|
|
24
|
+
clone(tree: CommitTree): CommitTree {
|
|
25
|
+
return {
|
|
26
|
+
errors: new Map(tree.errors),
|
|
27
|
+
components: new Map(tree.components),
|
|
28
|
+
contexts: new Map(tree.contexts),
|
|
29
|
+
|
|
30
|
+
commits: new Map(tree.commits),
|
|
31
|
+
roots: new Set(tree.roots),
|
|
32
|
+
}
|
|
33
|
+
},
|
|
24
34
|
getRootCommits: (tree: CommitTree) => {
|
|
25
|
-
return [...tree.roots]
|
|
35
|
+
return [...tree.roots]
|
|
36
|
+
.map(ref => tree.commits.get(ref.id))
|
|
37
|
+
.filter(c => !!c)
|
|
26
38
|
},
|
|
39
|
+
/**
|
|
40
|
+
* Each commit ID _may_ have an ErrorBoundaryState associated with it
|
|
41
|
+
* @param tree
|
|
42
|
+
* @param id
|
|
43
|
+
* @returns
|
|
44
|
+
*/
|
|
27
45
|
getError(tree: CommitTree, id: CommitID): ErrorBoundaryState {
|
|
28
46
|
if (tree.errors.has(id))
|
|
29
47
|
return tree.errors.get(id) as ErrorBoundaryState;
|
package/update.ts
CHANGED
|
@@ -22,38 +22,32 @@ export type Update = {
|
|
|
22
22
|
*/
|
|
23
23
|
next: null | Element;
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
* List of commits that _must_ re-render as a
|
|
27
|
-
* concequence of this update.
|
|
28
|
-
*/
|
|
29
|
-
targets: CommitRef[];
|
|
30
|
-
|
|
31
|
-
suspend: boolean,
|
|
25
|
+
// TODO: maybe expose prev/next index information?
|
|
32
26
|
moved: boolean,
|
|
33
27
|
};
|
|
34
28
|
|
|
35
29
|
export const Update = {
|
|
36
30
|
fresh: (ref: CommitRef, next: Element): Update => ({
|
|
37
|
-
ref, next, prev: null,
|
|
31
|
+
ref, next, prev: null, moved: false,
|
|
38
32
|
}),
|
|
39
33
|
existing: (prev: Commit, next: Element, moved: boolean = false): Update => ({
|
|
40
|
-
ref: prev, next, prev,
|
|
34
|
+
ref: prev, next, prev, moved,
|
|
41
35
|
}),
|
|
42
36
|
remove: (prev: Commit): Update => ({
|
|
43
|
-
ref: prev, next: null, prev,
|
|
37
|
+
ref: prev, next: null, prev, moved: false,
|
|
44
38
|
}),
|
|
45
|
-
distant: (root: Commit
|
|
46
|
-
ref: root, next: root.element, prev: root,
|
|
39
|
+
distant: (root: Commit): Update => ({
|
|
40
|
+
ref: root, next: root.element, prev: root, moved: false,
|
|
47
41
|
}),
|
|
48
|
-
skip: (prev: Commit
|
|
49
|
-
ref: prev, next: prev.element, prev,
|
|
42
|
+
skip: (prev: Commit): Update => ({
|
|
43
|
+
ref: prev, next: prev.element, prev, moved: false,
|
|
50
44
|
}),
|
|
51
45
|
target: (prev: Commit): Update => ({
|
|
52
|
-
ref: prev, next: prev.element, prev,
|
|
46
|
+
ref: prev, next: prev.element, prev, moved: false,
|
|
53
47
|
}),
|
|
54
48
|
suspend: (prev: Commit): Update => ({
|
|
55
|
-
ref: prev, next: prev.element, prev,
|
|
56
|
-
})
|
|
49
|
+
ref: prev, next: prev.element, prev, moved: false,
|
|
50
|
+
}),
|
|
57
51
|
}
|
|
58
52
|
|
|
59
53
|
/**
|
package/work.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type WorkRequestFunc<ID> = (callback: () => void) => ID;
|
|
2
|
+
export type WorkCancelFunc<ID> = (id: ID) => void;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A work manger is a service
|
|
6
|
+
*/
|
|
7
|
+
export type WorkManager<ID> = {
|
|
8
|
+
request: WorkRequestFunc<ID>,
|
|
9
|
+
cancel: WorkCancelFunc<ID>,
|
|
10
|
+
}
|