static _createTasksFromStartAndEndEvents(taskStartEvents, taskEndEvents, traceEndTs) {
    /** @type {TaskNode[]} */
    const tasks = [];
    // Create a reversed copy of the array to avoid copying the rest of the queue on every mutation.
    // i.e. pop() is O(1) while shift() is O(n), we take the earliest ts element off the queue *a lot*
    // so we'll optimize for having the earliest timestamp events at the end of the array.
    const taskEndEventsReverseQueue = taskEndEvents.slice().reverse();

    for (let i = 0; i < taskStartEvents.length; i++) {
      const taskStartEvent = taskStartEvents[i];
      if (taskStartEvent.ph === 'X') {
        // Task is a complete X event, we have all the information we need already.
        tasks.push(MainThreadTasks._createNewTaskNode(taskStartEvent));
        continue;
      }

      // Task is a B/E event pair, we need to find the matching E event.
      let matchedEventIndex = -1;
      let matchingNestedEventCount = 0;
      let matchingNestedEventIndex = i + 1;

      // We loop through the reversed end events queue from back to front because we still want to
      // see end events in increasing timestamp order.
      // While worst case we will loop through all events, the overwhelmingly common case is that
      // the immediate next event is our event of interest which makes this loop typically O(1).
      for (let j = taskEndEventsReverseQueue.length - 1; j >= 0; j--) {
        const endEvent = taskEndEventsReverseQueue[j];
        // We are considering an end event, so we'll count how many nested events we saw along the way.
        for (; matchingNestedEventIndex < taskStartEvents.length; matchingNestedEventIndex++) {
          if (taskStartEvents[matchingNestedEventIndex].ts >= endEvent.ts) break;

          if (taskStartEvents[matchingNestedEventIndex].name === taskStartEvent.name) {
            matchingNestedEventCount++;
          }
        }

        // The event doesn't have a matching name, skip it.
        if (endEvent.name !== taskStartEvent.name) continue;
        // The event has a timestamp that is too early, skip it.
        if (endEvent.ts < taskStartEvent.ts) continue;

        // The event matches our name and happened after start, the last thing to check is if it was for a nested event.
        if (matchingNestedEventCount > 0) {
          // If it was for a nested event, decrement our counter and move on.
          matchingNestedEventCount--;
          continue;
        }

        // If it wasn't, we found our matching E event! Mark the index and stop the loop.
        matchedEventIndex = j;
        break;
      }

      /** @type {LH.TraceEvent} */
      let taskEndEvent;
      let unbounded = false;
      if (matchedEventIndex === -1) {
        // If we couldn't find an end event, we'll assume it's the end of the trace.
        // If this creates invalid parent/child relationships it will be caught in the next step.
        taskEndEvent = {...taskStartEvent, ph: 'E', ts: traceEndTs};
        unbounded = true;
      } else if (matchedEventIndex === taskEndEventsReverseQueue.length - 1) {
        // Use .pop() in the common case where the immediately next event is needed.
        // It's ~25x faster, https://jsperf.com/pop-vs-splice.
        taskEndEvent = /** @type {LH.TraceEvent} */ (taskEndEventsReverseQueue.pop());
      } else {
        taskEndEvent = taskEndEventsReverseQueue.splice(matchedEventIndex, 1)[0];
      }

      const task = MainThreadTasks._createNewTaskNode(taskStartEvent, taskEndEvent);
      task.unbounded = unbounded;
      tasks.push(task);
    }

    if (taskEndEventsReverseQueue.length) {
      throw new Error(
        `Fatal trace logic error - ${taskEndEventsReverseQueue.length} unmatched end events`
      );
    }

    return tasks;
  }
This function takes the start and end events from a thread and creates tasks from them.
We do this by iterating through the start and end event arrays simultaneously. For each start
event we attempt to find its end event.

Because of this matching of start/end events and the need to be mutating our end events queue,
we reverse the array to more efficiently `.pop()` them off rather than `.shift()` them off.
While it's true the worst case runtime here is O(n^2), ~99.999% of the time the reverse loop is O(1)
because the overwhelmingly common case is that end event for a given start event is simply the very next event in our queue.

@param {LH.TraceEvent[]} taskStartEvents
@param {LH.TraceEvent[]} taskEndEvents
@param {number} traceEndTs
@return {TaskNode[]}