This repository has been archived by the owner on Dec 13, 2023. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 143
/
Reconciler.lua
545 lines (432 loc) · 15.2 KB
/
Reconciler.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
--[[
The reconciler uses the virtual DOM generated by components to create a real
tree of Roblox instances.
The reonciler has three basic modes of operation:
* reification (public as 'reify')
* reconciliation (private)
* teardown (public)
Reification is the process of creating new nodes in the tree. This is first
triggered when the user calls `Roact.reify` on a root element. This is where
the structure of the concrete tree is built, later used and modified by the
reconciliation step.
Reconciliation accepts an existing concrete instance tree (created by reify)
along with a new element that describes the desired new state.
The reconciler will do the minimum amount of work required to update the
instances to match the new element, sometimes invoking the reifier to create
new branches.
Teardown is the destructor for the tree. It will crawl through the tree,
destroying nodes in the correct order and invoking lifecycle methods.
]]
local Core = require(script.Parent.Core)
local Event = require(script.Parent.Event)
local Change = require(script.Parent.Change)
local getDefaultPropertyValue = require(script.Parent.getDefaultPropertyValue)
local SingleEventManager = require(script.Parent.SingleEventManager)
local Symbol = require(script.Parent.Symbol)
local isInstanceHandle = Symbol.named("isInstanceHandle")
local DEFAULT_SOURCE = "\n\t<Use Roact.setGlobalConfig with the 'elementTracing' key to enable detailed tracebacks>\n"
local function isPortal(element)
if type(element) ~= "table" then
return false
end
return element.component == Core.Portal
end
local Reconciler = {}
Reconciler._singleEventManager = SingleEventManager.new()
--[[
Is this element backed by a Roblox instance directly?
]]
local function isPrimitiveElement(element)
if type(element) ~= "table" then
return false
end
return type(element.component) == "string"
end
--[[
Is this element defined by a pure function?
]]
local function isFunctionalElement(element)
if type(element) ~= "table" then
return false
end
return type(element.component) == "function"
end
--[[
Is this element defined by a component class?
]]
local function isStatefulElement(element)
if type(element) ~= "table" then
return false
end
return type(element.component) == "table"
end
--[[
Destroy the given Roact instance, all of its descendants, and associated
Roblox instances owned by the components.
]]
function Reconciler.teardown(instanceHandle)
local element = instanceHandle._element
if isPrimitiveElement(element) then
-- We're destroying a Roblox Instance-based object
-- Kill refs before we make changes, since any mutations past this point
-- aren't relevant to components.
if element.props[Core.Ref] then
element.props[Core.Ref](nil)
end
for _, child in pairs(instanceHandle._reifiedChildren) do
Reconciler.teardown(child)
end
-- Necessary to make sure SingleEventManager doesn't leak references
Reconciler._singleEventManager:disconnectAll(instanceHandle._rbx)
instanceHandle._rbx:Destroy()
elseif isFunctionalElement(element) then
-- Functional components can return nil
if instanceHandle._reified then
Reconciler.teardown(instanceHandle._reified)
end
elseif isStatefulElement(element) then
-- Stop the component from setting state in willUnmount or anywhere thereafter.
instanceHandle._instance._canSetState = false
-- Tell the component we're about to tear everything down.
-- This gives it some notice!
if instanceHandle._instance.willUnmount then
instanceHandle._instance:willUnmount()
end
-- Stateful components can return nil from render()
if instanceHandle._reified then
Reconciler.teardown(instanceHandle._reified)
end
-- Cut our circular reference between the instance and its handle
instanceHandle._instance = nil
elseif isPortal(element) then
for _, child in pairs(instanceHandle._reifiedChildren) do
Reconciler.teardown(child)
end
else
error(("Cannot teardown invalid Roact instance %q"):format(tostring(element)))
end
end
--[[
Public interface to reifier. Hides parameters used when recursing down the
component tree.
]]
function Reconciler.reify(element, parent, key)
return Reconciler._reifyInternal(element, parent, key)
end
--[[
Instantiates components to represent the given element.
Parameters:
- `element`: The element to reify.
- `parent`: The Roblox object to contain the contained instances
- `key`: The Name to give the Roblox instance that gets created
- `context`: Used to pass Roact context values down the tree
The structure created by this method is important to the functionality of
the reconciliation methods; they depend on this structure being well-formed.
]]
function Reconciler._reifyInternal(element, parent, key, context)
if isPrimitiveElement(element) then
-- Primitive elements are backed directly by Roblox Instances.
local rbx = Instance.new(element.component)
-- Update Roblox properties
for key, value in pairs(element.props) do
Reconciler._setRbxProp(rbx, key, value, element)
end
-- Create children!
local reifiedChildren = {}
if element.props[Core.Children] then
for key, childElement in pairs(element.props[Core.Children]) do
local childInstance = Reconciler._reifyInternal(childElement, rbx, key, context)
reifiedChildren[key] = childInstance
end
end
-- This name can be passed through multiple components.
-- What's important is the final Roblox Instance receives the name
-- It's solely for debugging purposes; Roact doesn't use it.
if key then
rbx.Name = key
end
rbx.Parent = parent
-- Attach ref values, since the instance is initialized now.
if element.props[Core.Ref] then
element.props[Core.Ref](rbx)
end
return {
[isInstanceHandle] = true,
_key = key,
_parent = parent,
_element = element,
_context = context,
_reifiedChildren = reifiedChildren,
_rbx = rbx,
}
elseif isFunctionalElement(element) then
-- Functional elements contain 0 or 1 children.
local instanceHandle = {
[isInstanceHandle] = true,
_key = key,
_parent = parent,
_element = element,
_context = context,
}
local vdom = element.component(element.props)
if vdom then
instanceHandle._reified = Reconciler._reifyInternal(vdom, parent, key, context)
end
return instanceHandle
elseif isStatefulElement(element) then
-- Stateful elements have 0 or 1 children, and also have a backing
-- instance that can keep state.
-- We separate the instance's implementation from our handle to it.
local instanceHandle = {
[isInstanceHandle] = true,
_key = key,
_parent = parent,
_element = element,
_reified = nil,
}
local instance = element.component._new(element.props, context)
instanceHandle._instance = instance
instance:_reify(instanceHandle)
return instanceHandle
elseif isPortal(element) then
-- Portal elements have one or more children.
local target = element.props.target
if not target then
error(("Cannot reify Portal without specifying a target."):format(tostring(element)))
elseif typeof(target) ~= "Instance" then
error(("Cannot reify Portal with target of type %q."):format(typeof(target)))
end
-- Create children!
local reifiedChildren = {}
if element.props[Core.Children] then
for key, childElement in pairs(element.props[Core.Children]) do
local childInstance = Reconciler._reifyInternal(childElement, target, key, context)
reifiedChildren[key] = childInstance
end
end
return {
[isInstanceHandle] = true,
_key = key,
_parent = parent,
_element = element,
_context = context,
_reifiedChildren = reifiedChildren,
_rbx = target,
}
elseif typeof(element) == "boolean" then
-- Ignore booleans of either value
-- See https://github.com/Roblox/roact/issues/14
return nil
end
error(("Cannot reify invalid Roact element %q"):format(tostring(element)))
end
--[[
A public interface around _reconcileInternal
]]
function Reconciler.reconcile(instanceHandle, newElement)
if instanceHandle == nil or not instanceHandle[isInstanceHandle] then
local message = (
"Bad argument #1 to Reconciler.reconcile, expected component instance handle, found %s"
):format(
typeof(instanceHandle)
)
error(message, 2)
end
return Reconciler._reconcileInternal(instanceHandle, newElement)
end
--[[
Applies the state given by newElement to an existing Roact instance.
reconcile will return the instance that should be used. This instance can
be different than the one that was passed in.
]]
function Reconciler._reconcileInternal(instanceHandle, newElement)
local oldElement = instanceHandle._element
-- Instance was deleted!
if not newElement then
Reconciler.teardown(instanceHandle)
return nil
end
-- If the element changes type, we assume its subtree will be substantially
-- different. This lets us skip comparisons of a large swath of nodes.
if oldElement.component ~= newElement.component then
local parent = instanceHandle._parent
local key = instanceHandle._key
local context
if isStatefulElement(oldElement) then
context = instanceHandle._instance._context
else
context = instanceHandle._context
end
Reconciler.teardown(instanceHandle)
local newInstance = Reconciler._reifyInternal(newElement, parent, key, context)
return newInstance
end
if isPrimitiveElement(newElement) then
-- Roblox Instance change
local oldRef = oldElement[Core.Ref]
local newRef = newElement[Core.Ref]
local refChanged = (oldRef ~= newRef)
-- Cancel the old ref before we make changes. Apply the new one after.
if refChanged and oldRef then
oldRef(nil)
end
-- Update properties and children of the Roblox object.
Reconciler._reconcilePrimitiveProps(oldElement, newElement, instanceHandle._rbx)
Reconciler._reconcilePrimitiveChildren(instanceHandle, newElement)
instanceHandle._element = newElement
-- Apply the new ref if there was a ref change.
if refChanged and newRef then
newRef(instanceHandle._rbx)
end
return instanceHandle
elseif isFunctionalElement(newElement) then
instanceHandle._element = newElement
local rendered = newElement.component(newElement.props)
local newChild
if instanceHandle._reified then
-- Transition from tree to tree, even if 'rendered' is nil
newChild = Reconciler._reconcileInternal(instanceHandle._reified, rendered)
elseif rendered then
-- Transition from nil to new tree
newChild = Reconciler._reifyInternal(
rendered,
instanceHandle._parent,
instanceHandle._key,
instanceHandle._context
)
end
instanceHandle._reified = newChild
return instanceHandle
elseif isStatefulElement(newElement) then
instanceHandle._element = newElement
-- Stateful elements can take care of themselves.
instanceHandle._instance:_update(newElement.props)
return instanceHandle
elseif isPortal(newElement) then
if instanceHandle._rbx ~= newElement.props.target then
local parent = instanceHandle._parent
local key = instanceHandle._key
local context = instanceHandle._context
Reconciler.teardown(instanceHandle)
local newInstance = Reconciler._reifyInternal(newElement, parent, key, context)
return newInstance
end
Reconciler._reconcilePrimitiveChildren(instanceHandle, newElement)
instanceHandle._element = newElement
return instanceHandle
end
error(("Cannot reconcile to match invalid Roact element %q"):format(tostring(newElement)))
end
--[[
Reconciles the children of an existing Roact instance and the given element.
]]
function Reconciler._reconcilePrimitiveChildren(instance, newElement)
local elementChildren = newElement.props[Core.Children]
-- Reconcile existing children that were changed or removed
for key, childInstance in pairs(instance._reifiedChildren) do
local childElement = elementChildren and elementChildren[key]
childInstance = Reconciler._reconcileInternal(childInstance, childElement)
instance._reifiedChildren[key] = childInstance
end
-- Create children that were just added!
if elementChildren then
for key, childElement in pairs(elementChildren) do
-- Update if we didn't hit the child in the previous loop
if not instance._reifiedChildren[key] then
local childInstance = Reconciler._reifyInternal(childElement, instance._rbx, key, instance._context)
instance._reifiedChildren[key] = childInstance
end
end
end
end
--[[
Reconciles the properties between two primitive Roact elements and applies
the differences to the given Roblox object.
]]
function Reconciler._reconcilePrimitiveProps(fromElement, toElement, rbx)
local seenProps = {}
-- Set properties that were set with fromElement
for key, oldValue in pairs(fromElement.props) do
seenProps[key] = true
local newValue = toElement.props[key]
-- Assume any property that can be set to nil has a default value of nil
if newValue == nil then
local _, value = getDefaultPropertyValue(rbx.ClassName, key)
-- We don't care if getDefaultPropertyValue fails, because
-- _setRbxProp will catch the error below.
newValue = value
end
-- Roblox does this check for normal values, but we have special
-- properties like events that warrant this.
if oldValue ~= newValue then
Reconciler._setRbxProp(rbx, key, newValue, toElement)
end
end
-- Set properties that are new in toElement
for key, newValue in pairs(toElement.props) do
if not seenProps[key] then
seenProps[key] = true
local oldValue = fromElement.props[key]
if oldValue ~= newValue then
Reconciler._setRbxProp(rbx, key, newValue, toElement)
end
end
end
end
--[[
Used in _setRbxProp to avoid creating a new closure for every property set.
]]
local function set(rbx, key, value)
rbx[key] = value
end
--[[
Sets a property on a Roblox object, following Roact's rules for special
case properties.
This function can throw a couple different errors. In the future, calls to
_setRbxProp should be wrapped in a pcall to give better errors to the user.
For that to be useful, we'll need to attach a 'source' property on every
element, created using debug.traceback(), that points to where the element
was created.
]]
function Reconciler._setRbxProp(rbx, key, value, element)
if type(key) == "string" then
-- Regular property
local success, err = pcall(set, rbx, key, value)
if not success then
local source = element.source or DEFAULT_SOURCE
local message = ("Failed to set property %s on primitive instance of class %s\n%s\n%s"):format(
key,
rbx.ClassName,
err,
source
)
error(message, 0)
end
elseif type(key) == "table" then
-- Special property with extra data attached.
if key.type == Event then
Reconciler._singleEventManager:connect(rbx, key.name, value)
elseif key.type == Change then
Reconciler._singleEventManager:connectProperty(rbx, key.name, value)
else
local source = element.source or DEFAULT_SOURCE
-- luacheck: ignore 6
local message = ("Failed to set special property on primitive instance of class %s\nInvalid special property type %q\n%s"):format(
rbx.ClassName,
tostring(key.type),
source
)
error(message, 0)
end
elseif type(key) ~= "userdata" then
-- Userdata values are special markers, usually created by Symbol
-- They have no data attached other than being unique keys
local source = element.source or DEFAULT_SOURCE
local message = ("Properties with a key type of %q are not supported\n%s"):format(
type(key),
source
)
error(message, 0)
end
end
return Reconciler