@lukekaalim/act-recon 1.0.0 → 1.1.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 ADDED
@@ -0,0 +1,12 @@
1
+ # @lukekaalim/act-recon
2
+
3
+ ## 1.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4381035: Added error boundaries
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [4381035]
12
+ - @lukekaalim/act@3.1.0
package/algorithms.ts CHANGED
@@ -43,4 +43,24 @@ export type SortedChangeReport = ChangeReport & {
43
43
  };
44
44
  export const calculateSortedChangedElements = (): SortedChangeReport => {
45
45
  throw new MagicError();
46
- }
46
+ }
47
+
48
+ export const first = <X, Y>(array: ReadonlyArray<X>, func: (value: X, index: number) => Y | null): Y | null => {
49
+ for (let i = 0; i < array.length; i++) {
50
+ const value = array[i];
51
+ const result = func(value, i);
52
+ if (result !== null)
53
+ return result;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ export const last = <X, Y>(array: ReadonlyArray<X>, func: (value: X, index: number) => Y | null): Y | null => {
59
+ for (let i = array.length - 1; i > 0; i--) {
60
+ const value = array[i];
61
+ const result = func(value, i);
62
+ if (result !== null)
63
+ return result;
64
+ }
65
+ return null;
66
+ }
package/commit.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createId, Element, OpaqueID } from "@lukekaalim/act";
2
+ import { version } from "os";
2
3
 
3
4
  /**
4
5
  * A single consistent id representing a commit in the act tree.
@@ -66,4 +67,8 @@ export const Commit = {
66
67
  }
67
68
  },
68
69
  update: updateCommit,
70
+ version: (commit: Commit): Commit => ({
71
+ ...commit,
72
+ version: createId(),
73
+ }),
69
74
  }
package/context.ts CHANGED
@@ -1,8 +1,5 @@
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>;
1
+ import { Context, ContextID } from "@lukekaalim/act";
2
+ import { CommitID, CommitRef } from "./commit.ts";
6
3
 
7
4
  export type ContextState<T> = {
8
5
  id: CommitID,
@@ -11,50 +8,12 @@ export type ContextState<T> = {
11
8
  value: T,
12
9
  }
13
10
 
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);
11
+ export const findContext = <T>(contexts: Map<CommitID, ContextState<unknown>>, ref: CommitRef, context: Context<T>) => {
12
+ for (const id of [...ref.path].reverse()) {
13
+ const state = contexts.get(id);
14
+ if (state && state.contextId === context.id) {
15
+ return state;
58
16
  }
59
- };
17
+ }
18
+ return null;
60
19
  };
package/delta.ts CHANGED
@@ -1,14 +1,9 @@
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";
1
+ import { Commit, CommitRef } from "./commit.ts";
6
2
 
7
3
  export type CreateDelta = { ref: CommitRef, next: Commit };
8
4
  export type UpdateDelta = { ref: CommitRef, next: Commit, prev: Commit };
9
5
  export type RemoveDelta = { ref: CommitRef, prev: Commit };
10
- export type SkipDelta = { ref: CommitRef, next: Commit };
11
-
6
+ export type SkipDelta = { next: Commit };
12
7
 
13
8
  export type DeltaSet = {
14
9
  created: CreateDelta[],
@@ -16,85 +11,3 @@ export type DeltaSet = {
16
11
  skipped: SkipDelta[],
17
12
  removed: RemoveDelta[],
18
13
  };
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/element.ts ADDED
@@ -0,0 +1,156 @@
1
+ import {
2
+ ContextID, Element, errorBoundaryType, Node,
3
+ providerNodeType
4
+ } from "@lukekaalim/act";
5
+ import { Commit, CommitID, CommitRef } from "./commit";
6
+ import { loadHooks } from "./hooks";
7
+ import { ContextState } from "./context";
8
+ import { ComponentState, EffectID, EffectTask } from "./state";
9
+ import { CommitTree } from "./tree";
10
+
11
+ /**
12
+ * When processing an element, it may produce additional
13
+ * pieces of information: new targets, side effects, and boundary
14
+ * values
15
+ */
16
+ export type ElementOutput = {
17
+ child: Node,
18
+ reject: null | unknown,
19
+ effects: EffectTask[],
20
+ targets: CommitRef[],
21
+ };
22
+ export const ElementOutput = {
23
+ new: (child: Node): ElementOutput => ({
24
+ child,
25
+ reject: null,
26
+ effects: [],
27
+ targets: [],
28
+ })
29
+ }
30
+
31
+ export type ElementService = {
32
+ render(element: Element, ref: CommitRef): ElementOutput,
33
+ clear(ref: Commit): ElementOutput,
34
+
35
+ boundary: Map<CommitID, unknown>,
36
+ }
37
+
38
+ export const createElementService = (
39
+ tree: CommitTree,
40
+ requestRender: (ref: CommitRef) => void
41
+ ): ElementService => {
42
+ const contextStates = new Map<CommitID, ContextState<unknown>>();
43
+ const componentStates = new Map<CommitID, ComponentState>();
44
+ const boundaryValues = new Map<CommitID, unknown>();
45
+
46
+ const render = (
47
+ element: Element,
48
+ ref: CommitRef,
49
+ ): ElementOutput => {
50
+ const output = ElementOutput.new(element.children);
51
+
52
+ switch (typeof element.type) {
53
+ case 'string':
54
+ break;
55
+ case 'symbol':
56
+ switch (element.type) {
57
+ case providerNodeType: {
58
+ let state = contextStates.get(ref.id);
59
+ if (!state) {
60
+ state = {
61
+ id: ref.id,
62
+ contextId: element.props.id as ContextID,
63
+ value: element.props.value,
64
+ consumers: new Map(),
65
+ }
66
+ contextStates.set(ref.id, state);
67
+ }
68
+ if (state.value !== element.props.value) {
69
+ state.value = element.props.value;
70
+ output.targets.push(...state.consumers.values());
71
+ }
72
+ break;
73
+ }
74
+ case errorBoundaryType: {
75
+ const error = CommitTree.getError(tree, ref.id);
76
+ console.log(`Checking error boundary ${ref.id}`, error)
77
+ if (error.state === 'error')
78
+ output.child = null;
79
+ break;
80
+ }
81
+ default:
82
+ break;
83
+ }
84
+ break;
85
+ case 'function': {
86
+ let state = componentStates.get(ref.id);
87
+ if (!state) {
88
+ state = {
89
+ unmounted: false,
90
+ ref,
91
+ cleanups: new Map(),
92
+ contexts: new Map(),
93
+ values: new Map(),
94
+ deps: new Map(),
95
+ effects: new Map(),
96
+ }
97
+ componentStates.set(ref.id, state);
98
+ }
99
+ loadHooks(contextStates, requestRender, state, ref, output);
100
+ const props = {
101
+ ...element.props,
102
+ children: element.children,
103
+ } as Parameters<typeof element.type>[0];
104
+ try {
105
+ output.child = element.type(props);
106
+ } catch (thrownValue) {
107
+ output.child = null;
108
+ output.reject = thrownValue;
109
+ }
110
+ break;
111
+ }
112
+ default:
113
+ break;
114
+ }
115
+ return output;
116
+ }
117
+ const clear = (prev: Commit) => {
118
+ const output = ElementOutput.new(null);
119
+
120
+ switch (typeof prev.element.type) {
121
+ case 'symbol': {
122
+ switch (prev.element.type) {
123
+ case providerNodeType:
124
+ contextStates.delete(prev.id);
125
+ }
126
+ break;
127
+ }
128
+ case 'function': {
129
+ const componentState = componentStates.get(prev.id) as ComponentState;
130
+ componentState.unmounted = true;
131
+ for (const [,context] of componentState.contexts) {
132
+ if (context.state)
133
+ context.state.consumers.delete(prev.id);
134
+ }
135
+ for (const [index, cleanup] of componentState.cleanups) {
136
+ if (!cleanup)
137
+ continue;
138
+ const id = componentState.effects.get(index) as EffectID;
139
+ output.effects.push({
140
+ id,
141
+ ref: prev,
142
+ func: () => {
143
+ cleanup();
144
+ }
145
+ });
146
+ }
147
+ componentStates.delete(prev.id);
148
+ break;
149
+ }
150
+ }
151
+
152
+ return output;
153
+ }
154
+
155
+ return { render, clear, boundary: boundaryValues };
156
+ }
package/errors.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { CommitID } from "./commit"
2
+
3
+ export type ErrorBoundaryState = {
4
+ id: CommitID,
5
+ state: 'error' | 'normal',
6
+ value: unknown,
7
+ }
8
+
9
+ export const ErrorBoundaryState = {
10
+ create(id: CommitID): ErrorBoundaryState {
11
+ return { id, value: null, state: 'normal' };
12
+ },
13
+ clear(state: ErrorBoundaryState) {
14
+ state.state = 'normal';
15
+ },
16
+ set(state: ErrorBoundaryState, value: unknown) {
17
+ state.state = 'error';
18
+ state.value = value;
19
+ }
20
+ }
package/hooks.ts ADDED
@@ -0,0 +1,78 @@
1
+ import {
2
+ HookImplementation, hookImplementation, Context,
3
+ ValueOrCalculator, calculateValue, StateSetter,
4
+ runUpdater,
5
+ createId,
6
+ calculateDepsChange
7
+ } from "@lukekaalim/act";
8
+ import { ComponentState, EffectID } from "./state";
9
+ import { CommitID, CommitRef } from "./commit";
10
+ import { ElementOutput } from "./element";
11
+ import { ContextState, findContext } from "./context";
12
+
13
+ /**
14
+ * A fresh set of hook functions is created per component run.
15
+ */
16
+ export const loadHooks = (
17
+ contexts: Map<CommitID, ContextState<unknown>>,
18
+ requestRender: (ref: CommitRef) => void,
19
+
20
+ state: ComponentState,
21
+ ref: CommitRef,
22
+
23
+ output: ElementOutput
24
+ ) => {
25
+ let index = 0;
26
+ hookImplementation.useContext = <T>(context: Context<T>): T => {
27
+ let value = state.contexts.get(index);
28
+ if (!value) {
29
+ value = { state: findContext(contexts, ref, context) };
30
+ state.contexts.set(index, value);
31
+ if (value.state)
32
+ value.state.consumers.set(ref.id, ref);
33
+ }
34
+ if (value.state)
35
+ return value.state.value as T;
36
+ return context.defaultValue;
37
+ };
38
+ hookImplementation.useState = <T>(initialValue: ValueOrCalculator<T>) => {
39
+ const stateIndex = index++;
40
+ if (!state.values.has(stateIndex))
41
+ state.values.set(stateIndex, calculateValue(initialValue));
42
+
43
+ const value = state.values.get(stateIndex) as T;
44
+ const setValue: StateSetter<T> = (updater) => {
45
+ if (state.unmounted)
46
+ return;
47
+ const prevValue = state.values.get(stateIndex) as T;
48
+ const nextValue = runUpdater(prevValue, updater);
49
+ state.values.set(stateIndex, nextValue);
50
+ requestRender(ref);
51
+ };
52
+ return [value, setValue];
53
+ }
54
+ hookImplementation.useEffect = (effect, deps = null) => {
55
+ const effectIndex = index++;
56
+ if (!state.effects.has(effectIndex))
57
+ state.effects.set(effectIndex, createId());
58
+
59
+ const prevDeps = state.deps.get(effectIndex) || null;
60
+ const effectId = state.effects.get(effectIndex) as EffectID;
61
+ state.deps.set(effectIndex, deps);
62
+ const depsChanges = calculateDepsChange(prevDeps, deps)
63
+ if (depsChanges) {
64
+ output.effects.push({
65
+ id: effectId,
66
+ ref,
67
+ func() {
68
+ const prevCleanup = state.cleanups.get(effectId);
69
+ if (prevCleanup) {
70
+ state.cleanups.delete(effectId);
71
+ prevCleanup();
72
+ }
73
+ state.cleanups.set(effectId, effect());
74
+ }
75
+ });
76
+ }
77
+ };
78
+ };
package/mod.ts CHANGED
@@ -5,6 +5,5 @@ export * from './algorithms.ts';
5
5
  export * from './update.ts';
6
6
  export * from './thread.ts';
7
7
  export * from './tree.ts';
8
- export * from './effects.ts';
9
8
 
10
9
  export * from './reconciler.ts';
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@lukekaalim/act-recon",
3
3
  "type": "module",
4
- "version": "1.0.0",
4
+ "version": "1.1.0",
5
5
  "main": "mod.ts",
6
6
  "dependencies": {
7
- "@lukekaalim/act": "^3.0.0"
7
+ "@lukekaalim/act": "^3.1.0"
8
8
  }
9
9
  }
package/reconciler.ts CHANGED
@@ -1,8 +1,8 @@
1
-
2
- import { createThreadManager, WorkThread } from "./thread";
1
+ import { createThreadManager } from "./thread";
3
2
  import { CommitTree } from "./tree";
4
- import { ComponentService } from "./component";
5
3
  import { DeltaSet } from "./delta";
4
+ import { createElementService } from "./element";
5
+ import { EffectTask } from "./state";
6
6
 
7
7
  /**
8
8
  * A reconciler links all the relevant subsystems together.
@@ -16,21 +16,21 @@ export const createReconciler = (
16
16
  requestWork: () => void,
17
17
  ) => {
18
18
  const tree = CommitTree.new();
19
- const components = ComponentService.create(ref => {
20
- threads.request(ref);
21
- });
19
+ const elements = createElementService(tree, ref => threads.request(ref));
22
20
 
23
- const threads = createThreadManager(components, tree, requestWork, (deltas, effects) => {
21
+ const onThreadComplete = (deltas: DeltaSet, effects: EffectTask[]) => {
24
22
  render(deltas);
25
23
 
26
24
  // immedialty execute all side effects
27
25
  for (const effect of effects)
28
- effect.task();
29
- });
26
+ effect.func();
27
+ }
28
+
29
+ const threads = createThreadManager(elements, tree, requestWork, onThreadComplete);
30
30
 
31
31
  return {
32
32
  threads,
33
- components,
33
+ elements,
34
34
  tree,
35
35
  }
36
36
  }
package/state.ts CHANGED
@@ -1,118 +1,23 @@
1
- import { CommitRef, CommitID } from "./commit.ts";
2
- import { ContextManager } from "./context.ts";
1
+ import { CommitRef } from "./commit.ts";
2
+ import { ContextState } from "./context.ts";
3
3
  import * as act from '@lukekaalim/act';
4
- import { EffectManager, EffectID } from "./effects.ts";
5
- import { WorkThread } from "./thread.ts";
4
+
5
+
6
+ export type EffectID = act.OpaqueID<"EffectID">;
7
+ export type EffectTask = {
8
+ ref: CommitRef,
9
+ id: EffectID,
10
+ func: () => void,
11
+ }
6
12
 
7
13
  export type ComponentState = {
8
14
  ref: CommitRef;
9
15
 
16
+ unmounted: boolean,
17
+
10
18
  values: Map<number, unknown>;
11
19
  deps: Map<number, act.Deps>;
12
20
  effects: Map<number, EffectID>;
13
- contexts: Map<number, act.Context<unknown>>;
21
+ cleanups: Map<number, act.EffectCleanup>;
22
+ contexts: Map<number, { state: null | ContextState<unknown> }>;
14
23
  };
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 CHANGED
@@ -1,10 +1,12 @@
1
- import { convertNodeToElements, createId, Node } from "@lukekaalim/act";
1
+ import { convertNodeToElements, createId, ErrorBoundaryProps, errorBoundaryType, Node } from "@lukekaalim/act";
2
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";
3
+ import { DeltaSet } from "./delta.ts";
6
4
  import { CommitTree } from "./tree.ts";
7
- import { Update } from "./update.ts";
5
+ import { calculateUpdates, isDescendant, Update } from "./update.ts";
6
+ import { ElementService } from "./element.ts";
7
+ import { EffectTask } from "./state.ts";
8
+ import { ErrorBoundaryState } from "./errors.ts";
9
+ import { first, last } from "./algorithms.ts";
8
10
 
9
11
  /**
10
12
  * A WorkThread is a mutable data struture that
@@ -19,6 +21,9 @@ export type WorkThread = {
19
21
  pendingUpdates: Update[],
20
22
  pendingEffects: EffectTask[],
21
23
 
24
+ errorNotifications: Set<CommitID>,
25
+
26
+ visited: Set<CommitID>,
22
27
  deltas: DeltaSet,
23
28
  };
24
29
  export const WorkThread = {
@@ -27,6 +32,8 @@ export const WorkThread = {
27
32
  started: false,
28
33
  pendingEffects: [],
29
34
  pendingUpdates: [],
35
+ errorNotifications: new Set(),
36
+ visited: new Set(),
30
37
  deltas: {
31
38
  created: [],
32
39
  updated: [],
@@ -35,10 +42,45 @@ export const WorkThread = {
35
42
  },
36
43
  }
37
44
  },
45
+ /**
46
+ * Remove all changes from a particular commit onward
47
+ */
48
+ rollback(thread: WorkThread, from: CommitRef) {
49
+ console.log(`rolling back changes from ${from.id} in thread`);
50
+ thread.deltas.created = thread.deltas.created.filter(d => !isDescendant(from, d.ref));
51
+ thread.deltas.updated = thread.deltas.updated.filter(d => !isDescendant(from, d.ref));
52
+ thread.deltas.removed = thread.deltas.removed.filter(d => !isDescendant(from, d.ref));
53
+ thread.deltas.skipped = thread.deltas.skipped.filter(d => !isDescendant(from, d.next));
54
+ thread.pendingUpdates = thread.pendingUpdates.filter(update => !isDescendant(from, update.ref))
55
+ thread.pendingEffects = thread.pendingEffects.filter(effect => !isDescendant(from, effect.ref))
56
+ },
57
+ notifyError(thread: WorkThread, ref: CommitRef) {
58
+ thread.errorNotifications.add(ref.id);
59
+ },
60
+ /**
61
+ * Find the closest anscestor error boundary for a commit,
62
+ * either in the tree or one that was just created
63
+ * */
64
+ findClosestBoundary(thread: WorkThread, tree: CommitTree, ref: CommitRef): Commit | null {
65
+ return last(ref.path, id => {
66
+ console.log(ref.path, id);
67
+ if (tree.commits.has(id)) {
68
+ const commit = tree.commits.get(id) as Commit;
69
+ if (commit.element.type === errorBoundaryType)
70
+ return commit;
71
+ return null;
72
+ }
73
+ // We also might have just created the boundary
74
+ const freshBoundary = thread.deltas.created.find(c => c.ref.id === id && c.next.element.type === errorBoundaryType);
75
+ if (freshBoundary)
76
+ return freshBoundary.next;
77
+ return null;
78
+ });
79
+ }
38
80
  }
39
81
 
40
82
  export const createThreadManager = (
41
- comp: ComponentService,
83
+ elementService: ElementService,
42
84
  tree: CommitTree,
43
85
  requestWork: () => void,
44
86
  onThreadComplete: (deltas: DeltaSet, effects: EffectTask[]) => unknown = _ => {},
@@ -50,7 +92,7 @@ export const createThreadManager = (
50
92
  const run = () => {
51
93
  const update = currentThread.pendingUpdates.pop();
52
94
  if (update) {
53
- applyUpdate(tree, comp, currentThread, update);
95
+ applyUpdate(currentThread, update);
54
96
  }
55
97
  }
56
98
 
@@ -81,7 +123,7 @@ export const createThreadManager = (
81
123
  tree.commits.set(delta.ref.id, delta.next);
82
124
 
83
125
  for (const delta of currentThread.deltas.skipped)
84
- tree.commits.set(delta.ref.id, delta.next);
126
+ tree.commits.set(delta.next.id, delta.next);
85
127
 
86
128
  for (const delta of currentThread.deltas.updated)
87
129
  tree.commits.set(delta.ref.id, delta.next);
@@ -89,6 +131,23 @@ export const createThreadManager = (
89
131
  for (const delta of currentThread.deltas.removed)
90
132
  tree.commits.delete(delta.ref.id);
91
133
 
134
+ console.log(currentThread.errorNotifications);
135
+ for (const boundaryId of currentThread.errorNotifications) {
136
+ const commit = tree.commits.get(boundaryId) as Commit;
137
+ const { onError } = commit.element.props as ErrorBoundaryProps;
138
+ console.log({commit});
139
+ if (typeof onError === 'function') {
140
+ const state = CommitTree.getError(tree, commit.id);
141
+ console.log('informing the candidate')
142
+ const clear = () => {
143
+ console.log('clearing the error!')
144
+ ErrorBoundaryState.clear(state);
145
+ request(commit);
146
+ }
147
+ onError(state.value, clear);
148
+ }
149
+ }
150
+
92
151
  // Notify external
93
152
  onThreadComplete(currentThread.deltas, currentThread.pendingEffects);
94
153
 
@@ -109,10 +168,17 @@ export const createThreadManager = (
109
168
 
110
169
  const request = (target: CommitRef) => {
111
170
  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);
171
+ const updateOnPath = currentThread.pendingUpdates.find(update => {
172
+ return isDescendant(update.ref, target)
173
+ })
174
+ if (updateOnPath) {
175
+ if (updateOnPath.targets.some(target => target.id === target.id)) {
176
+ updateOnPath.targets.push(target);
177
+ }
178
+ } else {
179
+ console.log('adding pending task')
180
+ pendingUpdateTargets.set(target.id, target);
181
+ }
116
182
  } else {
117
183
  const roots = CommitTree.getRootCommits(tree);
118
184
  for (const root of roots) {
@@ -136,6 +202,85 @@ export const createThreadManager = (
136
202
  requestWork();
137
203
  }
138
204
 
205
+ const applyUpdate = (thread: WorkThread, { next, prev, ref, targets, suspend }: Update) => {
206
+ thread.visited.add(ref.id);
207
+
208
+ const identicalChange = next && prev && (next.id === prev.element.id);
209
+ const prevChildren = prev && prev.children
210
+ .map(c => tree.commits.get(c.id) as Commit) || [];
211
+
212
+ if (identicalChange) {
213
+ const isOnTargetPath = targets.some(target => target.path.includes(ref.id));
214
+ if (!isOnTargetPath)
215
+ return console.log('no action', prev.element);
216
+
217
+ const isSpecificallyTarget = targets.some(target => target.id === ref.id);
218
+
219
+ if (!isSpecificallyTarget) {
220
+ const updates = prevChildren.map(prev => Update.skip(prev, targets));
221
+ thread.pendingUpdates.push(...updates);
222
+
223
+ const commit = Commit.version(prev);
224
+ thread.deltas.skipped.push({ next: commit });
225
+ return console.log('skip', prev.element, targets);
226
+ }
227
+ }
228
+ if (next) {
229
+ const output = elementService.render(next, ref);
230
+ if (output.reject) {
231
+ const errorBoundary = WorkThread.findClosestBoundary(thread, tree, ref);
232
+ if (errorBoundary) {
233
+ const errorState = CommitTree.getError(tree, errorBoundary.id);
234
+ ErrorBoundaryState.set(errorState, output.reject);
235
+ WorkThread.rollback(thread, errorBoundary);
236
+ WorkThread.notifyError(thread, errorBoundary);
237
+
238
+ thread.pendingUpdates.push(Update.target(errorBoundary));
239
+ return console.log('rewinding to boundary', next);
240
+ } else {
241
+ console.error(output.reject);
242
+ console.error(`No boundary to catch error: Unmounting roots`);
243
+ for (const ref of tree.roots) {
244
+ WorkThread.rollback(thread, ref);
245
+ const prev = tree.commits.get(ref.id);
246
+ if (prev)
247
+ thread.pendingUpdates.push(Update.remove(prev));
248
+ }
249
+
250
+ }
251
+ }
252
+
253
+ const [childRefs, updates] = calculateUpdates(ref, prevChildren, output.child);
254
+
255
+ thread.pendingEffects.push(...output.effects);
256
+ thread.pendingUpdates.push(...updates.map(update => ({
257
+ ...update,
258
+ targets: [...targets, ...output.targets.filter(t => isDescendant(update.ref, t))]
259
+ })));
260
+
261
+ const commit = Commit.update(ref, next, childRefs);
262
+
263
+ if (prev)
264
+ (thread.deltas.updated.push({ ref, prev, next: commit }), console.log('update', commit.element));
265
+ else
266
+ (thread.deltas.created.push({ ref, next: commit }), console.log('create', commit.element));
267
+
268
+ // Update tree
269
+ //tree.commits.set(ref.id, commit);
270
+
271
+ return;
272
+ }
273
+ else if (prev && !next) {
274
+ const output = elementService.clear(prev);
275
+ thread.deltas.removed.push({ ref: prev, prev });
276
+ thread.pendingUpdates.push(...prevChildren.map(prev => Update.remove(prev)));
277
+ thread.pendingEffects.push(...output.effects);
278
+ return console.log('remove', prev.element);
279
+ } else {
280
+ throw new Error(`No prev, no next, did this commit ever exist?`)
281
+ }
282
+ };
283
+
139
284
  return { work, request, mount }
140
285
  };
141
286
 
package/tree.ts CHANGED
@@ -1,16 +1,42 @@
1
1
  import { Commit, CommitID, CommitRef } from "./commit.ts";
2
+ import { ContextState } from "./context.ts";
3
+ import { ErrorBoundaryState } from "./errors.ts";
4
+ import { ComponentState } from "./state.ts";
2
5
 
3
6
  export type CommitTree = {
7
+ components: Map<CommitID, ComponentState>,
8
+ contexts: Map<CommitID, ContextState<unknown>>,
9
+ errors: Map<CommitID, ErrorBoundaryState>,
10
+
4
11
  commits: Map<CommitID, Commit>,
5
12
  roots: Set<CommitRef>,
6
13
  }
7
14
 
8
15
  export const CommitTree = {
9
16
  new: (): CommitTree => ({
17
+ errors: new Map(),
18
+ components: new Map(),
19
+ contexts: new Map(),
20
+
10
21
  commits: new Map(),
11
22
  roots: new Set(),
12
23
  }),
13
24
  getRootCommits: (tree: CommitTree) => {
14
25
  return [...tree.roots].map(ref => tree.commits.get(ref.id) as Commit)
26
+ },
27
+ getError(tree: CommitTree, id: CommitID): ErrorBoundaryState {
28
+ if (tree.errors.has(id))
29
+ return tree.errors.get(id) as ErrorBoundaryState;
30
+ const state = ErrorBoundaryState.create(id);
31
+ tree.errors.set(id, state);
32
+ return state;
33
+ },
34
+ searchParents(tree: CommitTree, ref: CommitRef, func: (commit: Commit) => boolean): Commit | null {
35
+ for (const id of [...ref.path].reverse()) {
36
+ const commit = tree.commits.get(id) as Commit;
37
+ if (func(commit))
38
+ return commit
39
+ }
40
+ return null;
15
41
  }
16
42
  }
package/update.ts CHANGED
@@ -2,6 +2,10 @@ import { convertNodeToElements, createId, Element, Node } from "@lukekaalim/act"
2
2
  import { calculateChangedElements, ChangeEqualityTest } from "./algorithms.ts";
3
3
  import { Commit, CommitID, CommitRef } from "./commit.ts";
4
4
 
5
+ /**
6
+ * A request to transform part of a tree specified by
7
+ * the "ref"
8
+ */
5
9
  export type Update = {
6
10
  /**
7
11
  * The commit that should evaluate this
@@ -23,20 +27,31 @@ export type Update = {
23
27
  * concequence of this update.
24
28
  */
25
29
  targets: CommitRef[];
30
+
31
+ suspend: boolean,
26
32
  };
27
33
 
28
34
  export const Update = {
29
35
  fresh: (ref: CommitRef, next: Element): Update => ({
30
- ref, next, prev: null, targets: []
36
+ ref, next, prev: null, targets: [], suspend: false,
31
37
  }),
32
38
  existing: (prev: Commit, next: Element): Update => ({
33
- ref: prev, next, prev, targets: [],
39
+ ref: prev, next, prev, targets: [], suspend: false,
34
40
  }),
35
41
  remove: (prev: Commit): Update => ({
36
- ref: prev, next: null, prev, targets: [],
42
+ ref: prev, next: null, prev, targets: [], suspend: false,
37
43
  }),
38
44
  distant: (root: Commit, targets: CommitRef[]): Update => ({
39
- ref: root, next: root.element, prev: root, targets,
45
+ ref: root, next: root.element, prev: root, targets, suspend: false,
46
+ }),
47
+ skip: (prev: Commit, targets: CommitRef[]): Update => ({
48
+ ref: prev, next: prev.element, prev, targets, suspend: false,
49
+ }),
50
+ target: (prev: Commit): Update => ({
51
+ ref: prev, next: prev.element, prev, targets: [prev], suspend: false,
52
+ }),
53
+ suspend: (prev: Commit): Update => ({
54
+ ref: prev, next: prev.element, prev, targets: [], suspend: true,
40
55
  })
41
56
  }
42
57
 
package/component.ts DELETED
@@ -1,24 +0,0 @@
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/delta.test.ts DELETED
@@ -1,108 +0,0 @@
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/effects.ts DELETED
@@ -1,49 +0,0 @@
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>;