stdlib/promise/v2.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# backtick_javascript: true

# {Promise} is used to help structure asynchronous code.
#
# It is available in the Opal standard library, and can be required in any Opal
# application:
#
#     require 'promise/v2'
#
# ## Basic Usage
#
# Promises are created and returned as objects with the assumption that they
# will eventually be resolved or rejected, but never both. A {Promise} has
# a {#then} and {#fail} method (or one of their aliases) that can be used to
# register a block that gets called once resolved or rejected.
#
#     promise = PromiseV2.new
#
#     promise.then {
#       puts "resolved!"
#     }.fail {
#       puts "rejected!"
#     }
#
#     # some time later
#     promise.resolve
#
#     # => "resolved!"
#
# It is important to remember that a promise can only be resolved or rejected
# once, so the block will only ever be called once (or not at all).
#
# ## Resolving Promises
#
# To resolve a promise, means to inform the {Promise} that it has succeeded
# or evaluated to a useful value. {#resolve} can be passed a value which is
# then passed into the block handler:
#
#     def get_json
#       promise = PromiseV2.new
#
#       HTTP.get("some_url") do |req|
#         promise.resolve req.json
#       end
#
#       promise
#     end
#
#     get_json.then do |json|
#       puts "got some JSON from server"
#     end
#
# ## Rejecting Promises
#
# Promises are also designed to handle error cases, or situations where an
# outcome is not as expected. Taking the previous example, we can also pass
# a value to a {#reject} call, which passes that object to the registered
# {#fail} handler:
#
#     def get_json
#       promise = PromiseV2.new
#
#       HTTP.get("some_url") do |req|
#         if req.ok?
#           promise.resolve req.json
#         else
#           promise.reject req
#         end
#
#       promise
#     end
#
#     get_json.then {
#       # ...
#     }.fail { |req|
#       puts "it went wrong: #{req.message}"
#     }
#
# ## Chaining Promises
#
# Promises become even more useful when chained together. Each {#then} or
# {#fail} call returns a new {PromiseV2} which can be used to chain more and more
# handlers together.
#
#     promise.then { wait_for_something }.then { do_something_else }
#
# Rejections are propagated through the entire chain, so a "catch all" handler
# can be attached at the end of the tail:
#
#     promise.then { ... }.then { ... }.fail { ... }
#
# ## Composing Promises
#
# {PromiseV2.when} can be used to wait for more than one promise to resolve (or
# reject). Using the previous example, we could request two different json
# requests and wait for both to finish:
#
#     PromiseV2.when(get_json, get_json2).then |first, second|
#       puts "got two json payloads: #{first}, #{second}"
#     end
#

class PromiseV2 < `Promise`
  class << self
    def allocate
      ok, fail = nil, nil

      prom = `new self.$$constructor(function(_ok, _fail) { #{ok} = _ok; #{fail} = _fail; })`
      prom.instance_variable_set(:@type, :opal)
      prom.instance_variable_set(:@resolve_proc, ok)
      prom.instance_variable_set(:@reject_proc, fail)
      prom
    end

    def when(*promises)
      promises = Array(promises.length == 1 ? promises.first : promises)
      `Promise.all(#{promises})`.tap do |prom|
        prom.instance_variable_set(:@type, :when)
      end
    end

    def all_resolved(*promises)
      promises = Array(promises.length == 1 ? promises.first : promises)
      `Promise.allResolved(#{promises})`.tap do |prom|
        prom.instance_variable_set(:@type, :all_resolved)
      end
    end

    def any(*promises)
      promises = Array(promises.length == 1 ? promises.first : promises)
      `Promise.any(#{promises})`.tap do |prom|
        prom.instance_variable_set(:@type, :any)
      end
    end

    def race(*promises)
      promises = Array(promises.length == 1 ? promises.first : promises)
      `Promise.race(#{promises})`.tap do |prom|
        prom.instance_variable_set(:@type, :race)
      end
    end

    def resolve(value = nil)
      `Promise.resolve(#{value})`.tap do |prom|
        prom.instance_variable_set(:@type, :resolve)
        prom.instance_variable_set(:@realized, :resolve)
        prom.instance_variable_set(:@value_set, true)
        prom.instance_variable_set(:@value, value)
      end
    end

    def reject(value = nil)
      `Promise.reject(#{value})`.tap do |prom|
        prom.instance_variable_set(:@type, :reject)
        prom.instance_variable_set(:@realized, :reject)
        prom.instance_variable_set(:@value_set, true)
        prom.instance_variable_set(:@value, value)
      end
    end

    alias all when
    alias error reject
    alias value resolve
  end

  attr_reader :prev, :next

  # Is this promise native to JavaScript? This means, that methods like resolve
  # or reject won't be available.
  def native?
    @type != :opal
  end

  # Raise an exception when a non-JS-native method is called on a JS-native promise
  def nativity_check!
    raise ArgumentError, 'this promise is native to JavaScript' if native?
  end

  # Raise an exception when a non-JS-native method is called on a JS-native promise
  # but permits some typed promises
  def light_nativity_check!
    return if %i[reject resolve trace always fail then].include? @type
    raise ArgumentError, 'this promise is native to JavaScript' if native?
  end

  # Allow only one chain to be present, as needed by the previous implementation.
  # This isn't a strict check - it's always possible on the JS side to chain a
  # given block.
  def there_can_be_only_one!
    raise ArgumentError, 'a promise has already been chained' if @next && @next.any?
  end

  def gen_tracing_proc(passing, &block)
    proc do |i|
      res = passing.call(i)
      yield(res)
      res
    end
  end

  def resolve(value = nil)
    nativity_check!
    raise ArgumentError, 'this promise was already resolved' if @realized
    @value_set = true
    @value = value
    @realized = :resolve
    @resolve_proc.call(value)
    self
  end

  def reject(value = nil)
    nativity_check!
    raise ArgumentError, 'this promise was already resolved' if @realized
    @value_set = true
    @value = value
    @realized = :reject
    @reject_proc.call(value)
    self
  end

  def then(&block)
    prom = nil
    blk = gen_tracing_proc(block) do |val|
      prom.instance_variable_set(:@realized, :resolve)
      prom.instance_variable_set(:@value_set, true)
      prom.instance_variable_set(:@value, val)
    end
    prom = `self.then(#{blk})`
    prom.instance_variable_set(:@prev, self)
    prom.instance_variable_set(:@type, :then)
    (@next ||= []) << prom
    prom
  end

  def then!(&block)
    there_can_be_only_one!
    self.then(&block)
  end

  def fail(&block)
    prom = nil
    blk = gen_tracing_proc(block) do |val|
      prom.instance_variable_set(:@realized, :resolve)
      prom.instance_variable_set(:@value_set, true)
      prom.instance_variable_set(:@value, val)
    end
    prom = `self.catch(#{blk})`
    prom.instance_variable_set(:@prev, self)
    prom.instance_variable_set(:@type, :fail)
    (@next ||= []) << prom
    prom
  end

  def fail!(&block)
    there_can_be_only_one!
    fail(&block)
  end

  def always(&block)
    prom = nil
    blk = gen_tracing_proc(block) do |val|
      prom.instance_variable_set(:@realized, :resolve)
      prom.instance_variable_set(:@value_set, true)
      prom.instance_variable_set(:@value, val)
    end
    prom = `self.finally(function() { return blk(self.$value_internal()); })`
    prom.instance_variable_set(:@prev, self)
    prom.instance_variable_set(:@type, :always)
    (@next ||= []) << prom
    prom
  end

  def always!(&block)
    there_can_be_only_one!
    always(&block)
  end

  def trace(depth = nil, &block)
    prom = self.then do
      values = []
      prom = self
      while prom && (!depth || depth > 0)
        val = nil
        begin
          val = prom.value
        rescue ArgumentError
          val = :native
        end
        values.unshift(val)
        depth -= 1 if depth
        prom = prom.prev
      end
      yield(*values)
    end

    prom.instance_variable_set(:@type, :trace)
    prom
  end

  def trace!(*args, &block)
    there_can_be_only_one!
    trace(*args, &block)
  end

  def resolved?
    light_nativity_check!
    @realized == :resolve
  end

  def rejected?
    light_nativity_check!
    @realized == :reject
  end

  def realized?
    light_nativity_check!
    !@realized.nil?
  end

  def value
    if resolved?
      value_internal
    end
  end

  def error
    light_nativity_check!
    @value if rejected?
  end

  def and(*promises)
    promises = promises.map do |i|
      if PromiseV2 === i
        i
      else
        PromiseV2.value(i)
      end
    end
    PromiseV2.when(self, *promises).then do |a, *b|
      [*a, *b]
    end
  end

  def initialize(&block)
    yield self if block_given?
  end

  def to_v1
    v1 = PromiseV1.new

    self.then { |i| v1.resolve(i) }.rescue { |i| v1.reject(i) }

    v1
  end

  def inspect
    result = "#<#{self.class}"

    if @type
      result += ":#{@type}" unless %i[opal resolve reject].include? @type
    else
      result += ':native'
    end

    result += ":#{@realized}" if @realized
    result += "(#{object_id})"

    if @next && @next.any?
      result += " >> #{@next.inspect}"
    end

    result += ": #{value.inspect}"
    result += '>'

    result
  end

  alias catch fail
  alias catch! fail!
  alias do then
  alias do! then!
  alias ensure always
  alias ensure! always!
  alias finally always
  alias finally! always!
  alias reject! reject
  alias rescue fail
  alias rescue! fail!
  alias resolve! resolve
  alias to_n itself
  alias to_v2 itself

  private

  def value_internal
    if PromiseV2 === @value
      @value.value
    elsif @value_set
      @value
    elsif @prev
      @prev.value
    end
  end
end