@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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @lukekaalim/act-recon
2
2
 
3
+ ## 3.0.0-alpha.0
4
+
5
+ ### Major Changes
6
+
7
+ - b3f6c49: Added debug capabilities and protocol
8
+
9
+ ## 2.0.0
10
+
11
+ ### Major Changes
12
+
13
+ - Scheduling refactor
14
+
3
15
  ## 1.2.0
4
16
 
5
17
  ### Minor Changes
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 = componentStates.get(ref.id);
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
- componentStates.set(ref.id, state);
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 = componentStates.get(prev.id) as 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
- componentStates.delete(prev.id);
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.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';
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.ts';
11
+ export * from './reconciler';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lukekaalim/act-recon",
3
3
  "type": "module",
4
- "version": "1.2.0",
4
+ "version": "3.0.0-alpha.0",
5
5
  "main": "mod.ts",
6
6
  "dependencies": {
7
7
  "@lukekaalim/act": "^3.2.0"
package/reconciler.ts CHANGED
@@ -1,36 +1,112 @@
1
- import { createThreadManager } from "./thread";
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 { DeltaSet } from "./delta";
4
- import { createElementService } from "./element";
5
- import { EffectTask } from "./state";
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 elements = createElementService(tree, ref => threads.request(ref));
20
-
21
- const onThreadComplete = (deltas: DeltaSet, effects: EffectTask[]) => {
22
- render(deltas);
23
-
24
- // immedialty execute all side effects
25
- for (const effect of effects)
26
- effect.func();
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 threads = createThreadManager(elements, tree, requestWork, onThreadComplete);
87
+ const mount = (node: Node) => {
88
+ const thread = start();
89
+ const elements = convertNodeToElements(node)
30
90
 
31
- return {
32
- threads,
33
- elements,
34
- tree,
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
- * A WorkThread is a mutable data struture that
13
- * represents a particular "Tree Travesal Task".
16
+ * Apply a thread to a tree, modifying it's commit list
17
+ * to match the changes produced by the thread.
14
18
  *
15
- * Its expected when you start rendering, you
16
- * may start rendering more nodes due to updates.
19
+ * @param thread
20
+ * @param tree
17
21
  */
18
- export type WorkThread = {
19
- started: boolean,
20
-
21
- pendingUpdates: Update[],
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
- visited: Set<CommitID>,
27
- deltas: DeltaSet,
28
- };
29
- export const WorkThread = {
30
- new(): WorkThread {
31
- return {
32
- started: false,
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
- export const createThreadManager = (
81
- elementService: ElementService,
82
- tree: CommitTree,
83
- requestWork: () => void,
84
- onThreadComplete: (deltas: DeltaSet, effects: EffectTask[]) => unknown = _ => {},
85
- ) => {
86
- const pendingUpdateTargets = new Map<CommitID, CommitRef>();
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
- let currentThread = WorkThread.new();
54
+ thread.visited = new Set([...thread.visited].filter(v => !isDescendant(from, v)))
89
55
 
90
- const run = () => {
91
- const update = currentThread.pendingUpdates.pop();
92
- if (update) {
93
- applyUpdate(currentThread, update);
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
- const work = (test: () => boolean) => {
98
- let tick = 0;
61
+ const updateWorkThread = (thread: WorkThread, update: Update, tree: CommitTree, element: ElementService) => {
62
+ const { next, prev, ref, moved } = update;
99
63
 
100
- while (currentThread.pendingUpdates.length > 0) {
101
- run()
102
- tick++;
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
- if (currentThread.pendingUpdates.length === 0) {
105
- apply();
106
- }
68
+ if (identicalChange) {
69
+ const mustVisit = thread.mustVisit.has(ref.id);
70
+ if (!mustVisit)
71
+ return;
107
72
 
108
- // only test every 10 ticks
109
- if ((tick % 10 === 0) && test()) {
110
- // early exit
111
- return false;
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
- const apply = () => {
119
- // Update the tree.
120
- for (const delta of currentThread.deltas.created)
121
- tree.commits.set(delta.ref.id, delta.next);
122
-
123
- for (const delta of currentThread.deltas.skipped)
124
- tree.commits.set(delta.next.id, delta.next);
125
-
126
- for (const delta of currentThread.deltas.updated)
127
- tree.commits.set(delta.ref.id, delta.next);
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
- // Notify external
146
- onThreadComplete(currentThread.deltas, currentThread.pendingEffects);
109
+ const [childRefs, updates] = calculateUpdates(ref, prevChildren, output.child);
147
110
 
148
- // clear the thread
149
- currentThread = WorkThread.new();
111
+ thread.pendingEffects.push(...output.effects);
112
+ thread.pendingUpdates.push(...updates);
150
113
 
151
- // add any pending work that couldnt be completed last thread
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
- const request = (target: CommitRef) => {
166
- if (currentThread.started) {
167
- const updateOnPath = currentThread.pendingUpdates.find(update => {
168
- return isDescendant(update.ref, target)
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
- requestWork();
188
- }
121
+ // Update tree
122
+ //tree.commits.set(ref.id, commit);
189
123
 
190
- const mount = (root: Node) => {
191
- const elements = convertNodeToElements(root);
192
- for (const element of elements) {
193
- const id = createId<"CommitID">();
194
- const ref = { id, path: [id] };
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
- requestWork();
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
- const applyUpdate = (thread: WorkThread, { next, prev, ref, targets, moved }: Update) => {
203
- thread.visited.add(ref.id);
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
- const identicalChange = next && prev && (next.id === prev.element.id);
206
- const prevChildren = prev && prev.children
207
- .map(c => tree.commits.get(c.id) as Commit) || [];
152
+ thread.pendingUpdates.push({
153
+ ref,
154
+ prev,
155
+ next,
156
+ moved: false,
157
+ })
158
+ }
208
159
 
209
- if (identicalChange) {
210
- const isOnTargetPath = targets.some(target => target.path.includes(ref.id));
211
- if (!isOnTargetPath)
212
- return;
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
- const isSpecificallyTarget = targets.some(target => target.id === ref.id);
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
- if (!isSpecificallyTarget) {
217
- const updates = prevChildren.map(prev => Update.skip(prev, targets));
218
- thread.pendingUpdates.push(...updates);
219
-
220
- const commit = Commit.version(prev);
221
- thread.deltas.skipped.push({ next: commit });
222
- return;
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
- if (next) {
226
- const output = elementService.render(next, ref);
227
- if (output.reject) {
228
- const errorBoundary = WorkThread.findClosestBoundary(thread, tree, ref);
229
- if (errorBoundary) {
230
- const errorState = CommitTree.getError(tree, errorBoundary.id);
231
- ErrorBoundaryState.set(errorState, output.reject);
232
- WorkThread.rollback(thread, errorBoundary);
233
- WorkThread.notifyError(thread, errorBoundary);
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
- thread.pendingEffects.push(...output.effects);
253
- thread.pendingUpdates.push(...updates.map(update => ({
254
- ...update,
255
- targets: [...targets, ...output.targets.filter(t => isDescendant(update.ref, t))]
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
- const commit = Commit.update(ref, next, childRefs);
223
+ mustRender: Map<CommitID, CommitRef>,
224
+ mustVisit: Map<CommitID, CommitRef>,
259
225
 
260
- if (prev)
261
- thread.deltas.updated.push({ ref, prev, next: commit, moved });
262
- else
263
- thread.deltas.created.push({ ref, next: commit });
226
+ pendingUpdates: Update[],
227
+ pendingEffects: EffectTask[],
264
228
 
265
- // Update tree
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
- return { work, request, mount }
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 type ThreadManager = ReturnType<typeof createThreadManager>;
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].map(ref => tree.commits.get(ref.id) as Commit)
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, targets: [], suspend: false, moved: false,
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, targets: [], suspend: false, moved,
34
+ ref: prev, next, prev, moved,
41
35
  }),
42
36
  remove: (prev: Commit): Update => ({
43
- ref: prev, next: null, prev, targets: [], suspend: false, moved: false,
37
+ ref: prev, next: null, prev, moved: false,
44
38
  }),
45
- distant: (root: Commit, targets: CommitRef[]): Update => ({
46
- ref: root, next: root.element, prev: root, targets, suspend: false, moved: false,
39
+ distant: (root: Commit): Update => ({
40
+ ref: root, next: root.element, prev: root, moved: false,
47
41
  }),
48
- skip: (prev: Commit, targets: CommitRef[]): Update => ({
49
- ref: prev, next: prev.element, prev, targets, suspend: false, moved: false,
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, targets: [prev], suspend: false, moved: false,
46
+ ref: prev, next: prev.element, prev, moved: false,
53
47
  }),
54
48
  suspend: (prev: Commit): Update => ({
55
- ref: prev, next: prev.element, prev, targets: [], suspend: true, moved: false,
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
+ }