luau-promise/lib/Details.md
2019-09-10 17:12:00 -04:00

5.1 KiB

title
Implementation Details

Implementation Details

Chaining

One of the best parts about Promises is that they are chainable.

Every time you call andThen or catch, it returns a new Promise, which resolves with whatever value you return from the success or failure handlers, respectively.

somePromise:andThen(function(number)
  return number + 1
end):andThen(print)

You can also return a Promise from your handler, and it will be chained onto:

Promise.async(function(resolve)
	wait(1)
	resolve(1)
end):andThen(function(x)
	return Promise.async(function(resolve)
		wait(1)
		resolve(x + 1)
	end)
end):andThen(print) --> 2

You can also call :andThen multiple times on a single Promise to have multiple branches off of a single Promise.

Resolving a Promise with a Promise will be chained as well:

Promise.async(function(resolve)
	wait(1)
	resolve(Promise.async(function(resolve)
		wait(1)
		resolve(1)
	end))
end):andThen(print) --> 1

However, any value that is returned from the Promise executor (the function you pass into Promise.async) is discarded. Do not return values from the function executor.

Yielding in Promise executor

If you need to yield in the Promise executor, you must wrap your yielding code in a new thread to prevent your calling thread from yielding. The easiest way to do this is to use the constructor instead of :

Promise.async(function(resolve)
  wait(1)
  resolve()
end)

Promise.async uses Promise.new internally, except it wraps the Promise executor with .

Promise.async is sugar for:

Promise.new(function(resolve, reject, onCancel)
  Promise.spawn(function()
    -- ...
  end)
end)

Promise.spawn uses a BindableEvent internally to launch your Promise body on a fresh thread after waiting for the next RunService.Heartbeat event. The reason Promise.spawn includes this wait time is to ensure that your Promises have consistent timing. Otherwise, your Promise would run synchronously up to the first yield, and asynchronously afterwards. This can often lead to undesirable results. Additionally, Promises that never yield can resolve completely synchronously, and this can lead to unpredictable timing issues. Thus, we use Promise.spawn so there is always a guaranteed yield before execution.

::: danger Don't use regular spawn spawn might seem like a tempting alternative to Promise.spawn here, but you should never use it!

spawn (and wait, for that matter) do not resume threads at a consistent interval. If Roblox has resumed too many threads in a single Lua step, it will begin throttling and your thread that was meant to be resumed on the next frame could actually be resumed several seconds later. The unexpected delay caused by this behavior will cause cascading timing issues in your game and could lead to some potentially ugly bugs. :::

::: warning coroutine.wrap would work, but... coroutine.wrap is another possible stand-in for creating a BindableEvent and firing it off, but in the case of an error, the stack trace is reset when the coroutine executes. This can make troubleshooting extremely difficult because you don't know where to look in your code base for the source of the error. Creating a BindableEvent is relatively cheap, so you shouldn't need to worry about this causing performance problems in your game. :::

When to use Promise.new

In some cases, it is desirable for a Promise to execute completely synchronously. If you don't need to yield in your Promise executor, and you are aware of the timing implications of a completely synchronous Promise, then it is acceptable to use Promise.new.

However, in these situations, may be more appropriate.

Cancellation details

If a Promise is already cancelled at the time of calling its onCancel hook, the hook will be called immediately.

If you attach a :andThen or :catch handler to a Promise after it's been cancelled, the chained Promise will be instantly rejected with the error "Promise is cancelled".

If you cancel a Promise immediately after creating it in the same Lua cycle, the fate of the Promise is dependent on if the Promise handler yields or not. If the Promise handler resolves without yielding, then the Promise will already be settled by the time you are able to cancel it, thus any consumers of the Promise will have already been called.

If the Promise does yield, then cancelling it immediately will prevent its resolution. This is always the case when using Promise.spawn.

Attempting to cancel an already-settled Promise is ignored.

Cancellation propagation

When you cancel a Promise, the cancellation propagates up the Promise chain. Promises keep track of the number of consumers that they have, and when the upwards propagation encounters a Promise that no longer has any consumers, that Promise is cancelled as well.

It's important to note that cancellation does not propagate downstream, so if you get a handle to a Promise earlier in the chain and cancel it directly, Promises that are consuming the cancelled Promise will remain in an unsettled state forever.