@lukekaalim/act-recon 3.0.0-alpha.3 → 3.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.
@@ -0,0 +1,465 @@
1
+ # Lifecycle of an Update
2
+
3
+ Journey with us as we go through the code to see which
4
+ systems are responsible for the various parts of
5
+ managing an Act Tree inside `@lukekaalim/act-recon`.
6
+
7
+ We'll start at a button onclick handler, and end up
8
+ where we submit a packet of changes to our renderer.
9
+
10
+ > This description of these processes is a little simplified to skip some of the
11
+ > more complicated edge cases - so example code here does not 1-1 match
12
+ > the actual library code.
13
+
14
+ 1. [Clicking the Button](#1-clicking-the-button)
15
+ 2. [Inside the Reconciler](#2-inside-the-reconciler)
16
+ 3. [Queuing a new WorkTask](#3-queuing-a-new-work-task)
17
+ 4. [Working in the Thread](#4-working-in-the-thread)
18
+ 5. [Learning the History](#5-learning-the-history)
19
+ 6. [Running the Component](#6-running-the-component)
20
+ 7. [Generating the Diff](#7-generating-the-diff)
21
+ 8. [Handling the Output](#8-handling-the-output)
22
+ 9. [Submitting the Delta](#9-submitting-the-delta)
23
+ 10. [Conclusion](#10-conclusion)
24
+
25
+ ## 1. Clicking the Button
26
+
27
+ Typically, a user will interact with an application in some way - click a button,
28
+ type some text. Something like:
29
+
30
+ ```ts
31
+ const [count, setCount] = useState(0)
32
+
33
+ function onClick() {
34
+ setCount(count + 1)
35
+ }
36
+
37
+ return h('button', { onClick }, `Clicked ${count} times!`);
38
+
39
+ ```
40
+ Our "setCount" call is the function that starts everything off! Otherwise
41
+ referred to as the `setState` function.
42
+
43
+ The setState function generated by `useState()` has captured a few variables
44
+ internally when it was created:
45
+ - A ["CommitRef2"](/Reconciler#@lukekaalim/act-recon.CommitRef2) to a specific
46
+ part of our Act Tree where the calling component lives.
47
+ - A "ComponentState" - a mutable instance that stores all the variables relevant
48
+ for rendering a component, including such things as a map of
49
+ literal values of each `useState` call.
50
+ - The ["Reconciler"](/Reconciler#@lukekaalim/act-recon.Reconciler2) to which this entire app belongs.
51
+
52
+ So, to kick off our update, we need to two things: we need to update the internal
53
+ state of our component to use the new value provided to setState, and we need
54
+ to re-render the component. For the second one, we just ask the reconciler, and give it
55
+ that `ref` we had from before
56
+
57
+
58
+ ```ts
59
+ // somewhere
60
+ const ref: CommitRef2 = ...;
61
+ const componentState: ComponentState = ...;
62
+ const reconciler: Reconciler2 = ...;
63
+
64
+ // the second return value from useState
65
+ // e.g.
66
+ // [state, setState] = useState();
67
+ setState(newValue) {
68
+ // update our component state
69
+ componentState.values.set(hookId, newValue);
70
+ // then request our component be re-rendered
71
+ reconciler.render(ref);
72
+ }
73
+ ```
74
+
75
+ ## 2. Inside the Reconciler
76
+
77
+ Next stop: the reconciler. It's received a request
78
+ to re-render a specific component. It's mainly going
79
+ to delegate to it's [WorkThread](/Reconciler#@lukekaalim/act-recon.WorkThread2),
80
+ but while it does it will also tell the **Scheduler** that it should
81
+ get itself ready to do some work.
82
+
83
+ ```ts
84
+ // somewhere
85
+ const scheduler: Scheduler = ...;
86
+ const thread: WorkThread2 = ...;
87
+
88
+ render(ref: CommitRef2) {
89
+ thread.queue(ref);
90
+ scheduler.requestCallback();
91
+ }
92
+ ```
93
+
94
+ ## 3. Queuing a new WorkTask
95
+
96
+ Inside the WorkThread, it does a few checks before actually
97
+ promising to do any work:
98
+ - Am I already in the middle of a render? If so:
99
+ - Do I already have an update for this commit specifically?
100
+ If so, I don't actually need to do anything.
101
+ - Have I already rendered this Commit? I'm only allowed
102
+ to render a commit once per "pass", so I'll add this to my
103
+ "missed" queue, and I'll get started on it next pass.
104
+ - Am I currently rendering any of this commit's parents?
105
+ If so, just mark all the bits in-between as "MustVisit",
106
+ and that other update will handle the re-render.
107
+
108
+ If none of those conditions are met, the thread will add a
109
+ new [WorkTask](/Reconciler#@lukekaalim/act-recon.WorkTask) to it's queue.
110
+
111
+ To add this work task, we need to turn the CommitRef into the
112
+ actual commit it references - we can do this by just asking
113
+ the commit tree (which the WorkThread has access to)! We can
114
+ just pass the commit to once of the WorkTask constructors
115
+ to tell it what kind of operation we want to perform.
116
+
117
+ In this case, all we want is for the task to visit that node,
118
+ which should cause a re-render (thus updating our component).
119
+
120
+ ```ts
121
+ const tree: CommitTree2 = ...;
122
+
123
+ const missed = new Set<CommitID>();
124
+ const mustVisit = new Set<CommitID>();
125
+ const mustRender = new Set<CommitID>();
126
+ const visited = new Set<CommitID>();
127
+
128
+ const tasks: WorkTask[] = [];
129
+
130
+ queue(ref) {
131
+ if (mustRender.has(ref.id))
132
+ return;
133
+
134
+ if (visited.has(ref.id)) {
135
+ missed.add(ref);
136
+ return;
137
+ }
138
+
139
+ if (ref.path.find(ancestorId => tasks.find(task => task.ref.id === ancestorId))) {
140
+ ref.path.forEach(ancestorId => mustVisit.add(ancestorId))
141
+ return;
142
+ }
143
+
144
+ mustRender.add(ref.id);
145
+ mustVisit.add(ref.id);
146
+ const commit = tree.commits.get(ref.id) as Commit2;
147
+ tasks.push(WorkTask.visit(commit));
148
+ }
149
+
150
+ ```
151
+
152
+ ## 4. Working in the Thread
153
+
154
+ After adding a new tasks to the queue, nothing immediately happens. But
155
+ remember previously, when we called `scheduler.requestCallback`? At
156
+ some point later, the callback we requested will trigger, sending us back to the
157
+ reconciler.
158
+
159
+ The reconciler asks the thread if there is work to do (the thread checks it's tasks,
160
+ which we might have just added to). If there is, the reconciler asks the thread to work!
161
+
162
+ Kicking things off here, our thread just pops off the first task off it's task queue.
163
+ It does a few more checks to see if it can skip out on doing work, such as:
164
+ - Does this task just ask be to visit this node? If so:
165
+ - Is this node on my MustVisit list? Skip it not.
166
+ - Is this node something I have to directly render? If not
167
+ (aka I need to visit this node, but I don't need to render it),
168
+ just queue up some tasks to visit this node's children.
169
+
170
+ Otherwise, it will commit to doing the work - it's going to start
171
+ by asking the tree to process the commit we have inside the WorkTask.
172
+
173
+ The tree will give us an output object, which we will use later.
174
+
175
+ ```ts
176
+ const tree: CommitTree2 = ...;
177
+
178
+ work() {
179
+ const task = tasks.pop();
180
+
181
+ if (task.isVisiting()) {
182
+ if (!mustVisit.has(task.ref.id))
183
+ return;
184
+
185
+ if (!mustRender.has(task.ref.id)) {
186
+ const visits = task.commit.children
187
+ .map(child => WorkTask.visit(child));
188
+ tasks.push(visits);
189
+ return;
190
+ }
191
+ }
192
+
193
+ const output = tree.processElement(task.commit.element, task.commit)
194
+ // more later
195
+ }
196
+ ```
197
+
198
+ ## 5. Learning the History
199
+
200
+ A "Visit" task is actually just an instance of a generic task where the
201
+ "prev" element and the "next" element are the same - we know that our
202
+ component's special internal state has changed, not it's external props.
203
+
204
+ > Other types of Tasks often describe a specific change - the addition
205
+ > of more props or the changing of a value.
206
+
207
+ When a element is "processed" (like a component or "div"), it should
208
+ return some children, which should create additional tasks to process.
209
+
210
+ We want to make sure that children generated in the past are matched up
211
+ to the return values of the component, so when a component renders a second
212
+ time we can match up it's old children to it's new children.
213
+
214
+ Our tree's processing has a few tasks: first, it checks our commit's
215
+ history to see who our old children were, then figure out what kind
216
+ of element we are. There are some special cases for Providers and Boundaries,
217
+ but the one we care about is the "Component", or
218
+ `typeof element.type === 'function'` case.
219
+
220
+ The tree loads the previous children, and the components current state, before
221
+ passing it to the ElementOutput, telling it to process the component. The tree
222
+ also has a reference to the reconciler, which it passes along too.
223
+
224
+ ```ts
225
+ const reconciler: Reconciler2 = ...;
226
+ const componentStates: Map<CommitID, ComponentState> = ...;
227
+
228
+ processElement(element: Element, commit: CommitRef2) {
229
+ const output = new ElementOutput2()
230
+ output.prevChildren = commit.children.map(childRef => commits.get(childRef.id));
231
+
232
+ switch (typeof element.type) {
233
+ case 'function':
234
+ const state = componentStates.get(ref.id);
235
+
236
+ output.processComponent(commit.ref, element.type, element, reconciler, state);
237
+ return output;
238
+ }
239
+ }
240
+
241
+ ```
242
+
243
+ ## 6. Running the Component
244
+
245
+ The ElementOutput is finally responsible for determining all the things
246
+ that will change when the component is processed. First thing it
247
+ does it is sets up the Hook Globals - assigning implementations to
248
+ the hooks imported from `@lukekaalim/act`;
249
+
250
+ > This is actually where,
251
+ > from the first chapter, those variables like ComponentState and Reconciler
252
+ > are captured by our hooks.
253
+
254
+ Once everything is set up, we actually call our component function (finally!).
255
+
256
+ The results of the function (the element actually returned by our component)
257
+ is passed to our calculateDiff function, which uses the history we have from
258
+ our previous children, and our new result, the identify what nodes should
259
+ be created, updated, or destroyed.
260
+
261
+ ```ts
262
+
263
+ processComponent(ref, component, element, reconciler, componentState) {
264
+ // make hooks and set them up
265
+ hookImplementation.useState = function useState() {
266
+ const hookId = state.hookIndex++;
267
+
268
+ function setState(newValue) {
269
+ // update our component state
270
+ componentState.values.set(hookId, newValue);
271
+ // then request our component be re-rendered
272
+ reconciler.render(ref);
273
+ }
274
+ // ...blah
275
+ return [value, setState]
276
+ }
277
+ // and do so for useEffect & useContext...
278
+
279
+ // reset the hook counter, used to give
280
+ // each "call" of a hook a unique identifier
281
+ state.hookIndex = 0;
282
+
283
+ // run the component!
284
+ const result = component(element.props);
285
+
286
+ this.diff = this.calculateDiff(result);
287
+ }
288
+
289
+ ```
290
+ ## 7. Generating the Diff
291
+
292
+ We want to essentially learn four things:
293
+ - Stuff was there before but isn't now (Deleted)
294
+ - Stuff wasn't there before but is there now (Created)
295
+ - Stuff that moved around (Moved)
296
+ - Stuff that stayed the same (Persisted)
297
+
298
+ We'll call all these facts a "ChangeReport". We can actually compact
299
+ the representation a little into just two arrays: "transform" and "removed".
300
+
301
+ Element in transform are just array indices for where an element "was" in the previous
302
+ state. If the index is `-1`, that's the special case for when an element didn't exist before.
303
+
304
+ Element in the "removed" array are just elements that _were_ in the prev state, but not in the new
305
+ one.
306
+
307
+ We have some criteria to tell if something is considered "the same" (evaluated in this order):
308
+ 1. Are they the same "type" of element? If not, they aren't the same.
309
+ 2. Does either element have a key? If once of them does have a key,
310
+ and they don't match, then they aren't the same.
311
+ 3. Do they appear in the same index? Then they are the same!
312
+ 4. Otherwise, they are not the same.
313
+
314
+ We call this the `EqualityTest`, which we can represent as a function.
315
+
316
+ To combo all this up, we just run through both lists, marking stuff as visited
317
+ and add it to either "transform" or "removed" lists depending on
318
+ how it handles the equality test. Then, we take a look
319
+ through those lists, and create WorkTasks for each new change.
320
+
321
+ ```ts
322
+ const tasks: WorkTask[] = [];
323
+ const newChildren: CommitRef[] = []
324
+
325
+ calculateDiff(result) {
326
+ // prevChildren was given to us by the Tree
327
+ const changeReport = generateChangeReport(this.prevChildren, result)
328
+
329
+ for (/* loop through "transform" */) {
330
+ newChildren.push(child.ref);
331
+
332
+ if (moved) {
333
+ tasks.push(WorkTask.moved(child))
334
+ } else if (created) {
335
+ tasks.push(WorkTask.fresh(child))
336
+ } else {
337
+ tasks.push(WorkTask.update(child))
338
+ }
339
+ }
340
+ for (/* loop through "removed" */) {
341
+ tasks.push(WorkTask.remove(child))
342
+ }
343
+ }
344
+
345
+ generateChangeReport(prevs, nexts) {
346
+ const visited = new Set();
347
+ const transform = [];
348
+ const removed = [];
349
+
350
+ for (let nextIndex = 0; nextIndex < nexts.length; nextIndex++) {
351
+ const next = nexts[nextIndex];
352
+ const prevIndex = prevs.findIndex((prev, prevIndex) => equalityTest(prev, next, prevIndex, nextIndex));
353
+ transform.push(prevIndex);
354
+ if (prevIndex !== -1)
355
+ visited.add(prevIndex);
356
+ }
357
+ for (let i = 0; i < prevs.length; i++) {
358
+ if (!visited.has(i))
359
+ removed.push(i);
360
+ }
361
+
362
+ return { transform, removed };
363
+ }
364
+ ```
365
+
366
+ ## 8. Handling the Output
367
+
368
+ As you saw, the ElementOutput object bubbles all the way back up
369
+ to the Thread, and now we'll tackle the other half of the thread code
370
+ in the `work` function.
371
+
372
+ We update a few systems with the new output, such as:
373
+ - The Commit for the component is updated to reflect it's (possibly)
374
+ new children.
375
+ - The "previous" element, before the update, and the updated commit
376
+ are passed to a "Delta".
377
+ - The additional work tasks created are appended to the "tasks" array,
378
+ so our Thread will recursively keep following these instructions until
379
+ it "walks" through the entire affect tree.
380
+
381
+ ```ts
382
+ const tree: CommitTree2 = ...;
383
+
384
+ work() {
385
+
386
+ // ...as seen previously
387
+ const output = tree.processElement(task.commit.element, task.commit)
388
+
389
+ const oldElement = commit.element;
390
+ commit.update(element, output.newChildren);
391
+
392
+ this.delta.update(oldElement, commit);
393
+
394
+ this.tasks.push(...output.tasks);
395
+ }
396
+ ```
397
+
398
+ ## 9. Submitting the Delta
399
+
400
+ The "Delta" object that our thread updates represents all
401
+ the changes that a thread has recorded - containing
402
+ the "previous" and "next" state of any commit in a map. This structure
403
+ will be passed to the renderer when the thread runs out of tasks to do.
404
+
405
+ If a thread makes multiple changes to a single commit, only the final
406
+ change is recorded and sent to the renderer.
407
+
408
+ The thread discarded and a new one is created after it's delta is sent
409
+ to the renderer.
410
+
411
+ ```ts
412
+ // inside delta
413
+ updates: Map<CommitID, { prev: Element, next: Commit2 }>
414
+
415
+ update(prev: Element, next: Commit2, moved: boolean) {
416
+ const change = this.changed.get(next.ref.id);
417
+ if (change) {
418
+ change.next = next;
419
+ } else {
420
+ this.changed.set(next.ref.id, { prev, next, moved });
421
+ }
422
+ }
423
+
424
+ // inside reconciler
425
+ onTasksComplete() {
426
+ // store the old thread
427
+ const currentThread = this.thread;
428
+
429
+ // Start a new thread
430
+ this.thread = new WorkThread2(this.tree);
431
+
432
+ // send delta
433
+ this.renderer.render(currentThread.delta);
434
+ }
435
+ ```
436
+
437
+ ## 10. Conclusion
438
+
439
+ And with that, our renderer receives a lovely
440
+ package of a "Delta", which it will
441
+ then use to modify the existing DOM
442
+ to update our button's text.
443
+
444
+ You can see that our Component visit task
445
+ probably created a few more Update tasks
446
+ of it's own - so our reconciler will be
447
+ working for a few cycles until it runs out of
448
+ tasks and submits our delta.
449
+
450
+ We didn't cover all topics, such as:
451
+ - **How do you start the process?** The "mount" process is really
452
+ just a special case of the "update" process, where there is just
453
+ no previous history, and everything is a `WorkTask.create`.
454
+ "unmount" is also similar, where there is history but no future state.
455
+ - **What happens to "missed" updates?** We mentions that
456
+ a thread never repeats a node if it's already rendered it, saving it
457
+ for another pass and marking it missed.
458
+ Essentially, when our renderer finishes all it's current tasks,
459
+ it checks it's "missed" array, and if there are entries there, it
460
+ just converts them to tasks and starts again from the top, clearing
461
+ its internal state (except for it's "Delta", which accumulates over
462
+ these passes). Only when both "missed" and "tasks" is empty is a thread
463
+ considered "done".
464
+ - **What happens after the Delta is sent to the renderer?** This
465
+ is better covered in the [Lifecycle of a Render](/)