Skip to content

Latest commit

 

History

History
502 lines (352 loc) · 12.6 KB

File metadata and controls

502 lines (352 loc) · 12.6 KB

Cycle Protection

Fict includes built-in cycle protection to identify and stop infinite reactive loops. Development and canary builds can use configurable low thresholds and early warnings; every build, including production, retains a non-disableable high-threshold hard guard so a dynamic cycle cannot spin forever.

Overview

Reactive systems can accidentally create infinite loops when:

  • An effect updates a signal that it depends on
  • Multiple effects form a circular dependency chain
  • Component re-renders trigger effects that cause more re-renders

Fict's cycle protection monitors these patterns and provides helpful warnings in development mode. Production keeps the hard guard active even when the configurable diagnostic tier is disabled.

When Cycle Protection Activates

The configurable guard is enabled by default only in development mode (NODE_ENV !== 'production'). Production defaults to immutable ceilings of 100,000 effect runs per flush and 100 re-entries of the same root. setCycleProtectionOptions({ enabled: false }) cannot disable those ceilings. Internal canaries should enable the configurable tier explicitly and use lower thresholds so a regression is detected before the production hard limit.

Detection triggers in three scenarios:

Trigger Description
Flush Budget Exceeded Too many effects ran in a single microtask flush
Root Re-entry Depth A root context was re-entered too deeply (recursive component execution)
High Usage Window Multiple consecutive flush cycles used a high percentage of the budget

API Reference

setCycleProtectionOptions

Configure cycle protection thresholds and behavior.

import { setCycleProtectionOptions } from 'fict/advanced'

interface CycleProtectionOptions {
  /** Enable configurable guards and warnings (default: dev-only). Hard limits remain active. */
  enabled?: boolean
  /** Maximum effect runs allowed per microtask flush (default: 10,000) */
  maxFlushCyclesPerMicrotask?: number

  /** Maximum effect runs per flush (default: 20,000) */
  maxEffectRunsPerFlush?: number

  /** Number of flushes to track for high-usage detection (default: 5) */
  windowSize?: number

  /** Ratio threshold for high-usage warning (default: 0.8 = 80%) */
  highUsageRatio?: number

  /** Maximum root re-entry depth before warning (default: 10) */
  maxRootReentrantDepth?: number

  /** Whether to warn about sustained high usage patterns (default: true) */
  enableWindowWarning?: boolean

  /** If true, throw errors instead of warnings (useful for testing) */
  devMode?: boolean

  /** Enable threshold warnings before hard limit (default: dev-only true) */
  enableBackoffWarning?: boolean

  /** First warning threshold ratio (default: 0.5 = 50%) */
  backoffWarningRatio?: number
}

setCycleProtectionOptions(options: CycleProtectionOptions): void

The immutable ceilings are intentionally not options. Non-finite or oversized configured values are bounded to them, and the high-usage window is bounded to 100 entries.

Example:

import { setCycleProtectionOptions } from 'fict/advanced'

// Increase limits for a large application
setCycleProtectionOptions({
  maxFlushCyclesPerMicrotask: 50000,
  maxEffectRunsPerFlush: 100000,
})

// Strict mode for testing - throw errors instead of warnings
setCycleProtectionOptions({
  devMode: true,
  maxFlushCyclesPerMicrotask: 100,
})

// Recommended internal canary profile
setCycleProtectionOptions({
  enabled: true,
  devMode: false,
  maxFlushCyclesPerMicrotask: 10000,
  maxEffectRunsPerFlush: 20000,
  enableWindowWarning: true,
})

Configuration Options

enabled

Default: NODE_ENV !== 'production'

Controls the configurable diagnostic tier. It is enabled by default in development and disabled by default in production. The immutable production hard guard remains active in both cases. Enable this tier in canaries to enforce lower thresholds and high-usage warnings:

setCycleProtectionOptions({ enabled: true })

maxFlushCyclesPerMicrotask

Default: 10,000

Maximum number of effect runs allowed within a single microtask flush. If exceeded, cycle protection will warn (or throw in devMode) and stop processing further effects.

Values above the immutable 100,000-run ceiling are clamped. Setting enabled: false selects that ceiling rather than disabling cycle safety.

When to adjust:

  • Increase for large applications with many reactive computations
  • Decrease for stricter cycle detection during development
// Large app with complex reactive graph
setCycleProtectionOptions({
  maxFlushCyclesPerMicrotask: 50000,
})

// Strict development mode
setCycleProtectionOptions({
  maxFlushCyclesPerMicrotask: 100,
})

maxEffectRunsPerFlush

Default: 20,000

Similar to maxFlushCyclesPerMicrotask, but tracks total effect runs across the flush. This provides a secondary limit.

The effective limit is the lower of the two configured effect limits and the immutable 100,000-run ceiling.


windowSize

Default: 5

Number of consecutive flush cycles to track for high-usage pattern detection. The window tracks how much of the budget each flush used.

The retained window is capped at 100 entries. A sustained high-usage episode emits once; telemetry is re-armed only after usage drops below the threshold.


highUsageRatio

Default: 0.8 (80%)

When all flushes in the window use more than this ratio of their budget, a warning is triggered. This helps detect sustained high load that might indicate a performance problem.

Example:

If windowSize is 5 and highUsageRatio is 0.8, a warning triggers when 5 consecutive flushes each use 80% or more of the budget.

// More sensitive to sustained high usage
setCycleProtectionOptions({
  windowSize: 3,
  highUsageRatio: 0.6, // Warn at 60% usage over 3 flushes
})

maxRootReentrantDepth

Default: 10

Maximum depth of nested root context re-entry. This detects recursive component execution patterns that could indicate infinite loops.

Values above 100 are clamped to the immutable production ceiling.

// Stricter recursive detection
setCycleProtectionOptions({
  maxRootReentrantDepth: 5,
})

enableWindowWarning

Default: true

Toggle the high-usage window warning. Set to false to disable sustained usage warnings while keeping other protections active.

// Disable sustained usage warnings
setCycleProtectionOptions({
  enableWindowWarning: false,
})

devMode

Default: NODE_ENV !== 'production'

When true, cycle protection throws errors instead of logging warnings. Useful for:

  • Unit tests that should fail on cycle detection
  • Strict development environments
  • CI/CD pipelines
// For testing - fail fast on cycles
setCycleProtectionOptions({
  devMode: true,
  maxFlushCyclesPerMicrotask: 100,
})

DevTools Integration

Cycle protection integrates with the Fict DevTools hook. When a cycle is detected, the cycleDetected method is called with details:

interface CycleDetectedPayload {
  reason: 'flush-budget-exceeded' | 'root-reentry' | 'high-usage-window'
  detail?: {
    effectRuns?: number
    limit?: number
    hardLimit?: boolean
    depth?: number
    windowSize?: number
    ratio?: number
  }
}

DevTools can subscribe to these events for enhanced debugging visualization. The payload is bounded to numeric/boolean counters and never includes component names, source text, reactive values, or a dependency graph. Observer and console failures are contained and cannot weaken queue dropping or root re-entry blocking.


Common Patterns and Solutions

Pattern 1: Effect Updates Its Own Dependency

Problem:

function Counter() {
  let count = $state(0)

  // ❌ Infinite loop: effect reads and writes count
  $effect(() => {
    console.log(count)
    count++ // Updates count, which re-triggers the effect
  })

  return <div>{count}</div>
}

Solution:

function Counter() {
  let count = $state(0)

  // ✅ Use untrack to break the dependency
  $effect(() => {
    const current = count // Track read
    console.log(current)
    // Don't update the same signal in the effect
  })

  return <div>{count}</div>
}

Pattern 2: Cascading Effect Updates

Problem:

function Form() {
  let a = $state(0)
  let b = $state(0)

  // ❌ Effects trigger each other
  $effect(() => {
    b = a + 1
  })

  $effect(() => {
    a = b + 1
  })

  return (
    <div>
      {a} {b}
    </div>
  )
}

Solution:

function Form() {
  let a = $state(0)

  // ✅ Compute derived value instead of circular effects
  const b = a + 1 // Automatically derived

  return (
    <div>
      {a} {b}
    </div>
  )
}

Pattern 3: Effect Causes Component Re-execution

Problem:

function List() {
  let items = $state([])

  // ❌ Effect runs fetch which updates items, triggering re-execution
  $effect(() => {
    fetch('/api/items')
      .then(r => r.json())
      .then(data => {
        items = data // This can cause re-execution if items is read in control flow
      })
  })

  if (items.length > 0) {
    // Reading items in control flow...
  }

  return (
    <ul>
      {items.map(i => (
        <li key={i.id}>{i.name}</li>
      ))}
    </ul>
  )
}

Solution:

import { resource } from 'fict/plus'

// ✅ Use resource for data fetching
const itemsResource = resource(async ({ signal }) => {
  const res = await fetch('/api/items', { signal })
  return res.json()
})

function List() {
  const items = itemsResource.read()

  if (items.loading) return <div>Loading...</div>

  return (
    <ul>
      {items.data?.map(i => (
        <li key={i.id}>{i.name}</li>
      ))}
    </ul>
  )
}

Testing with Cycle Protection

For unit tests, enable strict mode to fail fast on cycle detection:

import { describe, it, expect, afterEach } from 'vitest'
import { setCycleProtectionOptions } from 'fict/advanced'
import { resetCycleProtectionStateForTests } from '@fictjs/runtime/internal'

describe('Reactive Tests', () => {
  afterEach(() => {
    // Reset state between tests
    resetCycleProtectionStateForTests()
  })

  it('should not create infinite loops', () => {
    setCycleProtectionOptions({
      devMode: true,
      maxFlushCyclesPerMicrotask: 100,
    })

    // Test code...
    // Will throw if cycle is detected
  })
})

Warning Messages

Cycle protection warnings follow this format:

[fict] cycle protection triggered: <reason>
Reason Description
flush-budget-exceeded Too many effects ran in one flush
root-reentry Root context re-entered too deeply
high-usage-window Sustained high usage over multiple flushes

Each warning includes context to help identify the issue:

// Example warning output
[fict] cycle protection triggered: flush-budget-exceeded { effectRuns: 10001 }
[fict] cycle protection triggered: root-reentry { depth: 11 }
[fict] cycle protection triggered: high-usage-window { windowSize: 5, ratio: 0.8 }

Production Behavior

In production (NODE_ENV === 'production'), cycle protection is disabled by default for maximum performance. You can opt in:

setCycleProtectionOptions({ enabled: true })

When disabled (default):

  • All cycle protection guards are no-ops
  • Zero runtime overhead
  • No warnings are emitted
  • Application continues even if cycles would occur

When enabled:

  • Guards run with the configured thresholds
  • devMode defaults to NODE_ENV !== 'production' (throw in dev, warn in prod)

Best Practices

  1. Keep default settings for most applications - The defaults are tuned for typical use cases

  2. Enable devMode in tests - Catch cycles early in your test suite

  3. Don't suppress warnings - Cycle warnings indicate real problems

  4. Use derived values instead of circular effects - Let the compiler handle dependencies

  5. Use resource for async data - Avoids effect/state update cycles

  6. Review high-usage warnings - They may indicate performance issues even if not infinite loops


See Also