← articlesripple internals

Ripple Runtime: Updates & Performance

Part 4 of the Ripple internals deep dive series.

Phase 3: User Interaction - Incrementing Count

Now that the component is mounted and all dependencies are registered, let's see what happens when the user clicks "Increment". This will change count from 0 to 1, triggering the update cycle that demonstrates how Ripple's reactivity system propagates changes.

Step 3.1: Event Handler Execution

User Action: Clicks button → @count++ executes

Compiled Code:

onClick: () => _$_.set(count, _$_.get(count) + 1)

Execution Flow:

Step 1: Read current value

_$_.get(count)  // Returns 0

Context:

tracking = false  // Not in tracking context (event handler)
active_reaction = null

Event handlers don't track dependencies because they're not reactive blocks. They simply read and write values without creating dependency relationships. This is important - event handlers are outside the reactive system, so they don't create dependencies when reading values.

Now let's see what happens when we actually update the value.

Step 2: Update value

_$_.set(count, 1)  // Set count to 1

Algorithm: set(count, 1)

FUNCTION set(count, 1):
    // Step 1: Check if mutations allowed
    IF NOT is_mutating_allowed:  // false (mutations allowed)
        THROW Error('Assignments not allowed during computed evaluation')

    // Step 2: Get old value
    old_value = count.__v  // 0

    // Step 3: Early exit if value unchanged (optimization!)
    IF 1 === 0:  // false - value changed
        RETURN

    // Step 4: Get associated block
    tracked_block = count.b  // root_block (or component_ctx)

    // Step 5: Store old value for teardown (if needed)
    IF (tracked_block.f & CONTAINS_TEARDOWN) !== 0:  // false
        // Skip (no teardown needed)

    // Step 6: Apply custom setter (none)
    IF count.a.set IS NOT undefined:  // false
        value = untrack(() => count.a.set(1, 0))

    // Step 7: Update value and clock (THE KEY!)
    count.__v = 1                    // Value updated
    count.c = increment_clock()      // Clock incremented!

    // Step 8: Schedule update to associated block
    schedule_update(count.b)  // This triggers the update cycle!
END FUNCTION

Algorithm: increment_clock()

FUNCTION increment_clock():
    clock = clock + 1  // Global clock: 0 → 1
    RETURN clock       // Returns 1
END FUNCTION

State Changes:

Before:

count = {
    __v: 0,        // Old value
    c: 0,          // Old clock
    b: root_block,
    // ...
}

clock = 0          // Global clock

After:

count = {
    __v: 1,        // Updated value
    c: 1,          // Clock incremented!
    b: root_block,
    // ...
}

clock = 1          // Global clock incremented

Key Point: The clock increment (count.c = 1) is crucial. This is how we detect changes later. When we check if a block is dirty, we compare count.c (current) with dependency.c (stored). Since 1 > 0, we know the value changed.

Clock-Based Change Detection:

Clock-Based Change Detection

Now that the value is updated and the clock is incremented, we need to schedule an update to the blocks that depend on this value.

Step 3.2: Update Scheduling - Queuing the Update

Now that count has changed, we need to tell the system "hey, something changed, please update!" This is done through the scheduling system.

Code:

schedule_update(count.b)  // count.b = root_block

Algorithm: schedule_update(root_block)

FUNCTION schedule_update(root_block):
    // Step 1: Queue microtask (batches updates)
    IF scheduler_mode === FLUSH_MICROTASK:  // true
        queue_microtask()  // Schedules flush_microtasks()

    // Step 2: Walk up block tree, marking blocks
    current = root_block

    WHILE current IS NOT NULL:
        flags = current.f  // ROOT_BLOCK

        // Optimization: Already marked? Skip
        IF (flags & CONTAINS_UPDATE) !== 0:  // false
            RETURN  // Already scheduled

        // Mark block as containing update
        current.f = flags | CONTAINS_UPDATE

        // Stop at root block (top of tree)
        IF (flags & ROOT_BLOCK) !== 0:  // true
            BREAK

        current = current.p  // Would go to parent (none for root)

    // Step 3: Add root block to update queue
    queued_root_blocks.push(root_block)
END FUNCTION

State Changes:

Before:

root_block.f = ROOT_BLOCK
queued_root_blocks = []
is_micro_task_queued = false

After:

root_block.f = ROOT_BLOCK | CONTAINS_UPDATE  // Marked for update
queued_root_blocks = [root_block]            // Queued
is_micro_task_queued = true                   // Microtask scheduled

Algorithm: queue_microtask()

FUNCTION queue_microtask():
    // Only queue once (batching optimization)
    IF NOT is_micro_task_queued:  // true
        is_micro_task_queued = true
        queueMicrotask(flush_microtasks)  // ← Browser API: runs after current task
END FUNCTION

Why Microtask Batching?

Instead of updating immediately, Ripple batches updates using queueMicrotask(). This means multiple rapid changes result in a single update cycle, improving performance by reducing DOM updates. Updates happen after the current code finishes, providing predictable timing.

Example:

@count++  // Schedules update
@count++  // Already queued, no new microtask
@count++  // Already queued, no new microtask
// All three changes processed in single flush!

Now that the update is scheduled, let's see what happens when the microtask executes and flushes the updates.

Step 3.3: Microtask Flush

When the microtask executes:

Algorithm: flush_microtasks()

FUNCTION flush_microtasks():
    is_micro_task_queued = false

    // Execute queued microtasks (none)
    IF queued_microtasks.length > 0:  // false
        // ...

    // Safety check
    IF flush_count > 1001:  // false
        RETURN

    // Flush root blocks
    previous_queued_root_blocks = queued_root_blocks  // [root_block]
    queued_root_blocks = []
    flush_queued_root_blocks(previous_queued_root_blocks)

    // Reset flush count
    IF NOT is_micro_task_queued:  // true
        flush_count = 0

    old_values.clear()
END FUNCTION

Algorithm: flush_queued_root_blocks([root_block])

FUNCTION flush_queued_root_blocks([root_block]):
    FOR EACH root_block IN [root_block]:
        flush_updates(root_block)
END FUNCTION

Step 3.4: Update Flush

Algorithm: flush_updates(root_block)

FUNCTION flush_updates(root_block):
    current = root_block
    containing_update = null
    effects = []

    // Depth-first traversal
    WHILE current IS NOT NULL:
        flags = current.f

        // Track containing update block
        IF (flags & CONTAINS_UPDATE) !== 0:  // true for root_block
            current.f = flags & ~CONTAINS_UPDATE  // Clear flag
            containing_update = root_block

        // Execute block if not paused and inside update boundary
        IF (flags & PAUSED) === 0 AND containing_update IS NOT NULL:  // true
            IF (flags & EFFECT_BLOCK) !== 0:  // false
                effects.push(current)
            ELSE:
                TRY:
                    // Check if dirty
                    IF is_block_dirty(current):  // Check this!
                        run_block(current)
                CATCH error:
                    handle_error(error, current)

            // Traverse to first child
            child = current.first  // count_render_block
            IF child IS NOT NULL:
                current = child
                CONTINUE

        // Move to next sibling or parent
        parent = current.p
        current = current.next

        // Walk up tree if no sibling
        WHILE current IS NULL AND parent IS NOT NULL:
            IF parent === containing_update:
                containing_update = null
            current = parent.next
            parent = parent.p

    // Execute effects
    FOR EACH effect IN effects:
        // ...
END FUNCTION

For root_block: is_block_dirty(root_block)

FUNCTION is_block_dirty(root_block):
    flags = root_block.f

    // Root blocks always execute
    IF (flags & (ROOT_BLOCK | BRANCH_BLOCK)) !== 0:  // true
        RETURN false  // Always dirty (always execute)
END FUNCTION

Root blocks and branch blocks always execute during flush_updates because they're structural elements that need to process their children. The is_block_dirty function returns false for these block types, but flush_updates still executes them because they serve as entry points for the traversal. The is_block_dirty check is primarily used for RENDER_BLOCK and EFFECT_BLOCK types to determine if they need re-execution.

For count_render_block: is_block_dirty(count_render_block)

FUNCTION is_block_dirty(count_render_block):
    flags = count_render_block.f

    // Not root or branch
    IF (flags & (ROOT_BLOCK | BRANCH_BLOCK)) !== 0:  // false
        RETURN false

    // Has run before
    IF (flags & BLOCK_HAS_RUN) === 0:  // false
        block.f = flags | BLOCK_HAS_RUN
        RETURN true

    // Check dependencies
    RETURN is_tracking_dirty(count_render_block.d)
END FUNCTION

Algorithm: is_tracking_dirty(count_render_block.d)

FUNCTION is_tracking_dirty(count_render_block.d):
    dependency_chain = count_render_block.d  // { c: 0, t: count, n: null }

    IF dependency_chain IS NULL:  // false
        RETURN false

    current = dependency_chain

    WHILE current IS NOT NULL:
        tracked = current.t  // count

        // Not derived
        IF (tracked.f & DERIVED) !== 0:  // false
            update_derived(tracked)

        // Check clock: count.c (1) > dependency.c (0)?
        IF tracked.c > current.c:  // 1 > 0 = true
            RETURN true  // DIRTY!

        current = current.n  // null

    RETURN false
END FUNCTION

Result: is_block_dirty(count_render_block) returns true - block is dirty!

Execution: run_block(count_render_block)

FUNCTION run_block(count_render_block):
    // Set context
    active_block = count_render_block
    active_reaction = count_render_block
    tracking = true
    active_dependency = null

    // Execute: _$_.set_text(__text0.firstChild, 'Count: ' + _$_.get(count))
    result = count_render_block.fn()

    // Inside: _$_.get(count)
    // Returns 1 (new value)
    // Registers dependency again (updates clock in dependency node)

    // Store dependency chain
    count_render_block.d = active_dependency
END FUNCTION

Dependency updated:

count_render_block.d = {
    c: 1,  // Updated to current clock value
    t: count,
    n: null
}

DOM updated: Text node now shows "Count: 1"

Step 3.5: Derived Value Update - The Cascade

Now here's the beautiful part: double depends on count, so when count changes, double needs to recompute. But it only recomputes when someone actually reads it!

Derived Value Recomputation Flow:

Derived Value Recomputation

The Flow:

  1. count changed → count.c: 0 → 1
  2. count_render_block updated (we saw this)
  3. branch_block executes (it always executes)
  4. Inside branch: _$_.get(double) is called
  5. This triggers derived value computation!

For branch_block: is_block_dirty(branch_block)

FUNCTION is_block_dirty(branch_block):
    flags = branch_block.f

    // Branch blocks always execute (they're control flow)
    IF (flags & BRANCH_BLOCK) !== 0:  // true
        RETURN false  // Always execute (not "dirty" but always runs)
END FUNCTION

Why branch blocks always execute: Branch blocks (if, for) are control flow - they need to check conditions every time. They don't track dependencies themselves, but their children do.

Inside branch: _$_.get(double) - First Access!

This is where derived value computation happens:

FUNCTION get(double):
    // double is derived
    IF (double.f & DERIVED) !== 0:  // true
        RETURN get_derived(double)  // ← Go here!
END FUNCTION

Algorithm: get_derived(double)

FUNCTION get_derived(double):
    // Step 1: Update derived value (compute if needed)
    update_derived(double)

    // Step 2: Register dependency (branch_block depends on double)
    IF tracking:  // true
        register_dependency(double)

    // Step 3: Apply custom getter (none)
    IF double.a.get IS NOT undefined:  // false
        double.__v = trigger_track_get(double.a.get, double.__v)

    RETURN double.__v  // 2
END FUNCTION

Algorithm: update_derived(double) - The Computation Check

FUNCTION update_derived(double):
    value = double.__v  // 0 (cached from before)

    // Check if needs recomputation
    IF value === UNINITIALIZED OR is_tracking_dirty(double.d):  // Check!
        // Dependencies changed, recompute!
        value = run_derived(double)

        // Update if value changed
        IF value !== double.__v:  // 2 !== 0 = true
            double.__v = 2                    // ← Update value
            double.c = increment_clock()     // ← Increment clock (2)
END FUNCTION

Algorithm: is_tracking_dirty(double.d) - Check if Count Changed

FUNCTION is_tracking_dirty(double.d):
    dependency_chain = double.d  // { c: 0, t: count, n: null }

    current = dependency_chain

    WHILE current IS NOT NULL:
        tracked = current.t  // count

        // THE KEY COMPARISON!
        // count.c (1) > dependency.c (0)?
        IF tracked.c > current.c:  // 1 > 0 = TRUE!
            RETURN true  // ← DIRTY! Dependencies changed!

        current = current.n  // null

    RETURN false
END FUNCTION

Comparison:

ValueBeforeAfter
count.c01 ← Changed!
double.d.c (stored)00
Comparison-1 > 0 = true

Result: Dependencies are dirty! Recompute double.

Algorithm: run_derived(double) - The Computation

FUNCTION run_derived(double):
    // Save context
    previous_block = active_block          // branch_block
    previous_reaction = active_reaction    // branch_block
    previous_tracking = tracking           // true
    previous_dependency = active_dependency // null
    previous_is_mutating_allowed = is_mutating_allowed  // true

    TRY:
        // Set context for computation
        active_block = null                // ← No block (computation context)
        active_reaction = double           // ← double is the reaction!
        tracking = true                     // ← Enable dependency tracking
        active_dependency = null           // ← Will track count
        is_mutating_allowed = false        // ← Prevent mutations during computation

        // Destroy old child blocks (none)
        destroy_computed_children(double)

        // Run computation: () => _$_.get(count) * 2
        value = double.fn()
        // Inside fn():
        //   _$_.get(count) → returns 1
        //   Registers count as dependency of double!
        //   1 * 2 = 2

        // No custom getter
        IF double.a.get IS NOT undefined:  // false
            value = trigger_track_get(double.a.get, value)

        RETURN value  // 2

    FINALLY:
        // Restore context
        active_block = previous_block
        active_reaction = previous_reaction
        tracking = previous_tracking
        active_dependency = previous_dependency
        is_mutating_allowed = previous_is_mutating_allowed
END FUNCTION

Context During Computation:

active_block = null              // ← No block
active_reaction = double          // ← double is the reaction!
tracking = true                   // ← Tracking enabled
active_dependency = null          // ← Will be built
is_mutating_allowed = false       // ← Mutations disabled

Inside double.fn(): _$_.get(count)

FUNCTION get_tracked(count):
    value = count.__v  // 1

    // Register dependency (tracking === true, active_reaction === double)
    IF tracking:  // true
        register_dependency(count)  // Registers to double, not branch_block!

    RETURN value  // 1
END FUNCTION

Dependency Chain Updated:

Before:

double.d = {
    c: 0,        // ← Old clock value
    t: count,
    n: null
}

After:

double.d = {
    c: 1,        // Updated to count's current clock!
    t: count,
    n: null
}

Derived Value Updated:

Before:

double = {
    __v: 0,        // ← Old computed value
    c: 1,          // ← Old clock
    d: { c: 0, t: count, n: null }
}

After:

double = {
    __v: 2,        // New computed value!
    c: 2,          // Clock incremented!
    d: {
        c: 1,      // Updated dependency clock
        t: count,
        n: null
    }
}

DOM Updated: Text node now shows "Double: 2"!

What Just Happened:

  1. double accessedget_derived(double) called
  2. Dependencies checkedcount.c (1) > double.d.c (0) = dirty!
  3. Computation randouble.fn() executed, read count (value 1)
  4. Dependency registereddouble.d updated to track count
  5. Value updateddouble.__v: 0 → 2, double.c: 1 → 2
  6. DOM updated → Text shows new value

Key Insight: Derived values are lazy and cached:

  • They only compute when accessed
  • They only recompute when dependencies are dirty
  • They cache the result for subsequent accesses

Phase 4: Toggling ShowDouble

Now let's see what happens when the user clicks the "Toggle" button. This will change showDouble from true to false, demonstrating how conditional blocks respond to changes in their dependencies.

User clicks "Toggle" button: @showDouble = !@showDouble

Step 4.1: Value Update

_$_.set(showDouble, !_$_.get(showDouble))

Execution:

  1. _$_.get(showDouble) returns true
  2. _$_.set(showDouble, false) executes

Algorithm: set(showDouble, false)

FUNCTION set(showDouble, false):
    old_value = showDouble.__v  // true

    // Value changed
    IF false === true:  // false
        RETURN

    // Update value and clock
    showDouble.__v = false
    showDouble.c = increment_clock()  // showDouble.c = 1

    // Schedule update
    schedule_update(showDouble.b)  // root_block
END FUNCTION

Result:

showDouble = {
    __v: false,
    c: 1,
    // ...
}

Step 4.2: Conditional Block Update

The if_block depends on showDouble, so it needs to re-execute.

For if_block: is_block_dirty(if_block)

FUNCTION is_block_dirty(if_block):
    // Check dependencies
    RETURN is_tracking_dirty(if_block.d)
END FUNCTION

FUNCTION is_tracking_dirty(if_block.d):
    dependency_chain = if_block.d  // { c: 0, t: showDouble, n: null }

    current = dependency_chain

    WHILE current IS NOT NULL:
        tracked = current.t  // showDouble

        // Check clock: showDouble.c (1) > dependency.c (0)?
        IF tracked.c > current.c:  // 1 > 0 = true
            RETURN true  // DIRTY!

        current = current.n

    RETURN false
END FUNCTION

Result: if_block is dirty!

Execution: run_block(if_block)

FUNCTION run_block(if_block):
    // Set context
    active_block = if_block
    active_reaction = if_block
    tracking = true
    active_dependency = null

    // Execute: if (_$_.get(showDouble)) { ... }
    result = if_block.fn()

    // Inside: _$_.get(showDouble) returns false
    // Registers dependency (updates clock)

    // Condition is false, so set_branch is NOT called
    // has_branch remains false

    // After execution:
    IF NOT has_branch:  // true
        update_branch(null, null)  // Destroy branch!
END FUNCTION

Algorithm: update_branch(null, null)

FUNCTION update_branch(null, null):
    condition = null

    // Destroy old branch
    IF b !== null:  // true (branch_block exists)
        destroy_block(branch_block)
        b = null
END FUNCTION

Algorithm: destroy_block(branch_block)

FUNCTION destroy_block(branch_block):
    // Destroy children recursively
    destroy_block_children(branch_block)

    // Run teardown
    run_teardown(branch_block)

    // Remove from DOM
    remove_block_dom(branch_block.s.start, branch_block.s.end)

    // Unlink from parent
    // (parent.first/last/next pointers updated)
END FUNCTION

Result: The <p>Double: 2</p> element is removed from the DOM!

Dependency updated:

if_block.d = {
    c: 1,  // Updated to showDouble's current clock
    t: showDouble,
    n: null
}

Phase 5: Toggling Back

To complete our understanding, let's see what happens when the user clicks "Toggle" again. This demonstrates how the system handles conditional blocks that are recreated, and how derived values use caching to avoid unnecessary recomputation.

User clicks "Toggle" again: @showDouble = !@showDouble

Step 5.1: Value Update

showDouble.__v = true
showDouble.c = 2

Step 5.2: Conditional Block Re-execution

if_block is dirty (showDouble.c (2) > dependency.c (1))

Execution:

  • _$_.get(showDouble) returns true
  • Condition is true, so set_branch() is called
  • New branch block is created
  • Double value is rendered again

Inside branch: _$_.get(double)

FUNCTION get_derived(double):
    update_derived(double)

    // Check dependencies
    is_tracking_dirty(double.d)
    // count.c (1) > double.d.c (1)?
    // 1 > 1 = false  // NOT dirty!
    // So use cached value

    RETURN double.__v  // 2 (cached!)
END FUNCTION

Key Point: The derived value is cached and doesn't recompute because count hasn't changed!

The Caching Check:

ValueCurrentStoredComparison
count.c111 > 1 = false
Result--Not dirty → Use cache!

This is the caching optimization in action - double was already computed when count was 1, so there's no need to recompute it again!


Performance Analysis

Now that we've traced through the complete lifecycle of our component, let's analyze the performance characteristics of Ripple's reactivity system. Understanding these metrics helps explain why the system is efficient and scales well.

Time Complexity

OperationComplexityNotes
get() (simple)O(1)Constant time read
get() (derived)O(C + D)Computation + dependency check
set()O(H)Height of block tree
is_block_dirty()O(D)Dependency chain traversal
flush_updates()O(B)All blocks in tree

Where:

  • C = computation time
  • D = dependency count
  • H = block tree height
  • B = total blocks

Space Complexity

StructureComplexityNotes
Tracked objectO(1)Fixed size
Dependency chainO(D)One node per dependency
Block treeO(B)One block per reactive unit

Optimizations Observed

  1. Dependency Deduplication: Same tracked value accessed multiple times creates only one dependency
  2. Clock-Based Change Detection: O(1) comparison vs O(N) equality check
  3. Lazy Derived Computation: Computed only when accessed and dirty
  4. Cached Derived Values: Don't recompute if dependencies unchanged
  5. Early Exit: set() exits if value unchanged
  6. Microtask Batching: Single DOM update cycle per event loop turn

Key Insights: The Big Picture

1. Fine-Grained Reactivity

Only the specific blocks that depend on changed values re-execute. In our example:

When count changes:

  • count_render_block executes (depends on count)
  • double computation runs (depends on count)
  • if_block doesn't execute (doesn't depend on count)
  • Root block doesn't re-execute (not dirty)

When showDouble changes:

  • if_block executes (depends on showDouble)
  • count_render_block doesn't execute (doesn't depend on showDouble)
  • double doesn't recompute (not accessed)

The Magic: Each block only re-executes when its specific dependencies change!

2. Dependency Chain Structure

Dependencies form a linked list on each block:

Dependency Chain Linked List

block.d → Dependency1 → Dependency2 → Dependency3 → null
           ↓            ↓            ↓
         {c, t, n}   {c, t, n}   {c, t, n}

Each dependency node stores:

  • c: Clock value at registration time (for comparison)
  • t: Reference to tracked value (which value it depends on)
  • n: Next dependency (forms linked list)

Example from our component:

count_render_block.d → { c: 0, t: count, n: null }

This says: "This block depends on count, registered when count's clock was 0."

3. Clock-Based Change Detection

Instead of comparing values (expensive!), Ripple compares clock values:

Clock Change Detection Flow

// Expensive (O(N) for objects):
if (tracked.__v !== dependency.stored_value) {
    // Value changed!
}

// Efficient (O(1)):
if (tracked.c > dependency.c) {
    // Value changed! (clock incremented)
}

Why this works:

  • Every set() increments the clock
  • Dependencies store the clock value when registered
  • Comparison is just integer comparison (O(1))

Example:

// Initial state
count.c = 0
dependency.c = 0  // Registered when clock was 0

// After change
count.c = 1       // ← Incremented by set()
dependency.c = 0  // ← Still 0

// Check: 1 > 0 = true → DIRTY!

4. Derived Value Caching

Derived values cache their result and only recompute when:

Conditions for recomputation:

  1. First access: __v === UNINITIALIZED
  2. Dependencies dirty: is_tracking_dirty(double.d) === true

Otherwise: Return cached value!

Example from our component:

// First access: count = 0
double.__v = 0  // Computed

// count changes to 1
count.c = 1

// Access double again
is_tracking_dirty(double.d)  // 1 > 0 = true
double.__v = 2  // Recomputed

// Access double again (count still 1)
is_tracking_dirty(double.d)  // 1 > 1 = false
double.__v = 2  // Cached! No recomputation!

This prevents unnecessary computation - if count hasn't changed, double doesn't recompute!

5. Block Tree Execution

Blocks form a tree structure:

Block Tree Execution

RootBlock (always executes)
├─→ RenderBlock (count) - executes if count changed
├─→ IfBlock (showDouble) - executes if showDouble changed
│   └─→ BranchBlock (double) - executes if condition true
│       └─→ RenderBlock (double text) - executes if double changed
└─→ RenderBlock (buttons) - always executes

Update Propagation:

  • Updates start at root block
  • Traverse down the tree (depth-first)
  • Only dirty blocks execute
  • Clean blocks skip execution

Visual Flow:

Update Propagation Flow

This is fine-grained reactivity - only what needs to update, updates!

Now that we've explored the key insights, let's summarize everything we've learned by tracing our component through its entire lifecycle: