Scheduling in Unison.js

Introduction

In JavaScript, understanding the call stack, event loop, microtasks, and the microtask queue is essential for knowing how asynchronous code is handled. Let’s go through each of these and their relationships:

1. Call Stack

The call stack is a stack data structure that keeps track of function calls. JavaScript is single-threaded, so it can only do one thing at a time. When a function is invoked, it’s pushed onto the call stack. When the function completes, it’s popped off the stack. If a function calls another function, the called function is pushed onto the stack, and so on. The call stack executes in a Last In, First Out (LIFO) order.

  • Example: If functionA calls functionB, functionB is pushed onto the stack after functionA.

    function functionA() {
      functionB();
    }
    
    function functionB() {
      console.log('Inside functionB');
    }
    
    functionA();

In summary: The call stack is where synchronous code is executed.

2. Event Loop

The event loop is responsible for coordinating the execution of code, handling events, and processing asynchronous tasks. It checks the call stack and the task queues (macrotask queue and microtask queue), ensuring JavaScript executes tasks in the right order.

  • How it works:
    1. The event loop continuously monitors the call stack.
    2. When the call stack is empty (i.e., all synchronous code has executed), the event loop checks the microtask queue.
    3. If the microtask queue has tasks, they are moved to the call stack and executed before moving on to any other tasks.
    4. After all microtasks are complete, the event loop checks the macrotask queue (often just called the task queue) for tasks.

In summary: The event loop manages and coordinates between the call stack, microtasks, and macrotasks.

3. Microtasks and the Microtask Queue

Microtasks are a type of asynchronous task that are given higher priority than other tasks (macrotasks). They’re used for operations that need to happen immediately after the current operation, before any new rendering or other events.

  • Examples of Microtasks:
    • Promise resolutions (via .then() or .catch())
    • MutationObserver callbacks
  • Microtask Queue:
    • The microtask queue holds microtasks to be executed as soon as the call stack is clear.
    • When the call stack is empty, the event loop first processes all tasks in the microtask queue before moving to the macrotask queue.

In summary: Microtasks have higher priority and are executed right after the call stack is empty, before any macrotasks.

4. Relationship between Call Stack, Event Loop, Microtask Queue, and Macrotask Queue

  1. Call Stack: Holds and executes synchronous code. When asynchronous operations are encountered, they don’t block the call stack. Instead, they are sent to either the microtask queue or macrotask queue, depending on their type.

  2. Microtask Queue: Microtasks (like resolved Promises) go here and are given high priority. The event loop checks the microtask queue immediately after the call stack is empty. Only when the microtask queue is empty does it proceed to macrotasks.

  3. Event Loop: The event loop manages when code is executed. It continually checks the call stack and decides what to execute based on the availability of tasks in the call stack and the microtask and macrotask queues.

  4. Macrotask Queue (or simply the task queue): Contains tasks like setTimeout callbacks, setInterval callbacks, and I/O tasks. The event loop only checks this queue after the call stack and microtask queue are both clear.

5. Interactive Demo

Example to Illustrate Relationships

console.log('Script start');

setTimeout(() => {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise resolved');
});

console.log('Script end');

Execution Breakdown:

  1. Call Stack:

    • “Script start” is printed immediately because it’s synchronous.
    • setTimeout is encountered, so it’s placed in the macrotask queue to run after the current execution context.
    • Promise.resolve().then(...) creates a microtask to be executed after the call stack is empty.
    • “Script end” is printed immediately because it’s also synchronous.
  2. Event Loop:

    • After the synchronous code completes, the call stack is empty.
    • The event loop now checks the microtask queue (which has the resolved Promise).
    • “Promise resolved” is printed.
    • Once the microtask queue is empty, the event loop checks the macrotask queue (which has the setTimeout callback).
    • “setTimeout” is printed.

Final Output:

Script start
Script end
Promise resolved
setTimeout

In short:

  • Synchronous code runs first.
  • The microtask queue is processed after synchronous code but before macrotasks.
  • Macrotasks are processed only after synchronous code and microtasks are fully handled.

This mechanism helps JavaScript handle asynchronous operations efficiently while maintaining predictable and organized execution behavior.

Vue scheduler

In Vue, a job represents a unit of work, often a function that needs to run in response to changes in reactive data. Jobs are typically updates or side effects triggered when reactive data is modified, such as re-rendering a component or updating DOM elements.

How Vue’s Job Scheduling Works

Vue’s scheduling mechanism for jobs is similar to JavaScript’s asynchronous scheduling in a single-threaded environment, but with some custom rules to optimize performance and responsiveness. Vue maintains two distinct job queues, which we’ll call the “pre” queue and the “post” queue, each with different timings for when they are processed.

  1. Pre Queue:

    • Jobs in the “pre” queue are flushed first, immediately after the current JavaScript call stack clears.
    • Typical jobs in the “pre” queue include watchers and computed property re-evaluations that need to run before other tasks.
  2. Post Queue:

    • Jobs in the “post” queue are always flushed after the “pre” queue, regardless of when they were added.
    • Common examples of “post” queue jobs include cleanup tasks or DOM update effects that don’t need immediate execution.

The strict ordering of these queues ensures that “pre” jobs always run before “post” jobs in any given cycle, preserving the correct order of updates.

How Job Queuing and Flushing Work

  • When a job is triggered by a reactive change, it’s added to the appropriate queue. Before adding, Vue’s scheduler checks if a flush is already scheduled.
  • If no flush is scheduled, Vue schedules one to run in the near future, using a microtask (such as Promise.then) to ensure the flush occurs as soon as the current call stack is clear.
  • Each job can only be queued once per cycle, meaning duplicate jobs are ignored until the next flush cycle.

This mechanism prevents redundant reactivity updates and ensures that jobs are processed in batches, only after synchronous code has completed.

Example of Job Scheduling with Pre and Post Queues

Consider the following:

import { ref, watchEffect, nextTick } from 'vue';

const count = ref(0);

watchEffect(() => {
  console.log('Count changed:', count.value);
  // This is a "pre" queue job and will run as soon as the reactive data changes.
});

// Simulating a reactive data change
count.value++;
console.log('Synchronous log');

Output:

Synchronous log
Count changed: 1

In this example:

  1. count.value++ triggers a change in reactive data.
  2. The watcher is queued in the “pre” queue.
  3. The “pre” queue flushes immediately after the current call stack (so “Synchronous log” logs first).
  4. Only after the call stack clears does the watcher output “Count changed: 1”.

How nextTick Works in Vue

nextTick is a utility in Vue that allows you to schedule code to run after the current flush is complete. It returns a Promise that resolves when all currently pending updates are applied. If no flush is scheduled, nextTick returns a promise that is already resolved.

count.value++;
nextTick().then(() => {
  console.log('Flushed updates');
});
// Logs 'Flushed updates' after 'Count changed: 1'

Summary of Key Points

  • Pre Queue: Runs tasks that need to be handled immediately after reactive data changes (like watchers).
  • Post Queue: Runs cleanup tasks and effects that can wait until after “pre” queue jobs.
  • Job Deduplication: Ensures that each job is only queued once per cycle.
  • Flush Scheduling: Uses microtasks to flush all queued jobs once the call stack is clear.
  • nextTick: Allows for code to run after all updates are flushed, returning a promise.

This system ensures that all reactive updates in Vue are handled efficiently, preserving consistency and minimizing unnecessary re-renders.

Connecting the Vue scheduler with React (manual flushing mode)

How the @unisonjs/core Library Coordinates Scheduling in Vue and React

In JavaScript frameworks, Vue and React each handle scheduling independently, both using microtasks. However, they differ in the order and timing of flushes, which can cause issues when trying to synchronize updates across both. Specifically, Vue flushes occur before React flushes by default. This means that, without intervention, Vue’s job queues are usually empty by the time React processes updates, causing potential misalignment between the states of the two libraries.

The @unisonjs/core library addresses this by allowing the Vue scheduler to operate in a manual mode, enabling controlled coordination of job flushing across both frameworks.

Key Features and Mechanisms of @unisonjs/core

  1. Switching Vue Scheduler to Manual Mode:

    • When @unisonjs/core initiates manual mode in Vue, a controller counter is incremented to keep track of how many components or parts of the application are using manual scheduling.
    • This allows Vue jobs to be queued without being immediately flushed, letting React and Vue jobs accumulate and sync before processing.
  2. Tracking Job Positions:

    • Each job that is queued in Vue (via @unisonjs/core) returns an index indicating its position in the queue.
    • This enables components to track the specific jobs (or “effects”) they’ve declared, allowing them to precisely flush their jobs at the right time.
    • Components can then manage job timing across frameworks, ensuring that each effect runs in a synchronized manner.
  3. Flushing Specific Jobs:

    • @unisonjs/core offers specific functions for controlled flushing:
      • flushJobAt(index: number): Flushes a single job at a specific position in the “pre” queue.
      • flushPostJobAt(index: number): Flushes a single job at a specific position in the “post” queue.
      • flushJobsUntil(index: number): Flushes all jobs up to a specific position in the “pre” queue, ideal for batch processing.
      • flushPostJobsUntil(index: number): Flushes all jobs up to a specific position in the “post” queue.
    • By using these functions, components can precisely control when jobs are executed, allowing for smooth coordination with React’s scheduling.
  4. Reverting to Automatic Mode:

    • Once manual mode is no longer required, components can request a return to automatic mode. Each time a component requests to exit manual mode, the controller counter is decremented.
    • When the controller counter reaches zero, @unisonjs/core automatically returns Vue’s scheduler to auto mode, and all remaining jobs are flushed in the usual order.
  5. Manual Notification of Flush Completion:

    • Since nextTick relies on job flushing to be complete, in manual mode, @unisonjs/core requires explicit notification when flushing ends.
    • This notification triggers the nextTick promise resolution, ensuring that subsequent actions dependent on the flush can proceed correctly.

Example of Using @unisonjs/core to Control Job Flushing

Let’s consider a scenario where we have a Vue scheduler and a React component that need to stay synchronized:

import { flushJobAt, flushJobsUntil, nextTick, switchToManual, switchToAuto } from '@unisonjs/core';

function Counter() {
  const [count, setCount] = useState(0);
  const jobIndex = useRef(null);

  function increment() {
    setCount(count + 1);

    // Switch scheduler to manual mode
    switchToManual();
    jobIndex.current = queueJob(() => {
      console.log('log count :', count);
    });
  }

  useEffect(() => {
    // Flush the job and all the previous
    flushJobsUntil(jobIndex.current);

    // After manual control is complete, switch scheduling back to auto mode
    switchToAuto();
  });

  // Use nextTick to act after flushing completes
  nextTick().then(() => {
    console.log('All jobs flushed');
  });
  return <button>count is {count}</button>;
}

Summary of @unisonjs/core’s Key Benefits

  • Manual Mode Control: Switches Vue’s scheduler to manual mode to align job flushing with React, avoiding premature flushes.
  • Precise Job Tracking: Enables specific jobs or groups of jobs to be flushed as needed, reducing desynchronization risks.
  • Automatic Mode Reversion: Ensures Vue’s scheduler returns to normal when manual control is no longer needed, flushing any remaining jobs.
  • NextTick Compatibility: Allows nextTick to work correctly by manually signaling when flushes are complete, so code dependent on Vue’s reactive cycle can operate seamlessly.

This approach allows @unisonjs/core to effectively synchronize Vue and React, offering fine-grained control over job flushing and maintaining consistent application state across both libraries.