@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 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
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@lukekaalim/act-recon",
3
+ "type": "module",
4
+ "version": "1.0.0",
5
+ "main": "mod.ts",
6
+ "dependencies": {
7
+ "@lukekaalim/act": "^3.0.0"
8
+ }
9
+ }
package/readme.md ADDED
@@ -0,0 +1,3 @@
1
+ # `@lukekaalim/act-recon`
2
+
3
+ A node reconciliation library for `@lukekaalim/act`.
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
+ }