Skip to content

saqqdy/untiljs

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

36 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

untiljs

Promise-based one-time watch for changes - Framework Agnostic

NPM version Codacy Badge tree shaking typescript Test coverage npm download gzip License

Sonar

Read this in other languages: English | 简体中文

Features

  • Framework Agnostic - Works with Vue, React, Angular, Svelte, Node.js, and vanilla JavaScript
  • TypeScript Support - Full type definitions included
  • Multiple Input Types - Supports getter functions, Subscribable objects, RefLike objects, and plain values
  • Deep Comparison - Configurable depth for object/array comparison
  • Timeout Support - Built-in timeout handling with optional error throwing
  • Tree-shakeable - Only bundle what you use
  • Zero Dependencies - Only js-cool as a lightweight runtime dependency
  • Well Tested - 227+ test cases with comprehensive coverage

Experience Online

Installation

# use pnpm
$ pnpm install untiljs

# use npm
$ npm install untiljs --save

# use yarn
$ yarn add untiljs

Quick Start

import until from 'untiljs'

// Basic usage with getter function
let value = 1
setTimeout(() => {
  value = 2
}, 1000)

await until(() => value).toBe(2)
console.log('Value is now 2!')

API Reference

WatchSource

A WatchSource<T> can be one of:

Type Example Description
Getter Function () => value A function that returns the current value
Subscribable { value, subscribe } An object with value property and subscribe method
RefLike { value } An object with value property (e.g., Vue ref)
Plain Value 5, 'hello' A static value

Subscribable Interface

interface Subscribable<T> {
  readonly value: T
  subscribe(callback: (value: T) => void): () => void
}

Methods

Method Description
toBe(value, options?) Wait until the source equals the given value
toMatch(condition, options?) Wait until the condition returns true
toBeTruthy(options?) Wait until the value is truthy
toBeNull(options?) Wait until the value is null
toBeUndefined(options?) Wait until the value is undefined
toBeNaN(options?) Wait until the value is NaN
changed(options?) Wait until the value changes
changedTimes(n, options?) Wait until the value changes n times
toContains(value, options?) Wait until array contains value (arrays only)
not.* Inverse of any method above

Options

interface UntilToMatchOptions {
  /** Timeout in milliseconds (0 = never timeout) */
  timeout?: number

  /** Reject promise on timeout (default: false) */
  throwOnTimeout?: boolean

  /** Deep comparison depth (true = unlimited, number = specific depth) */
  deep?: boolean | number
}

Usage Examples

Basic Usage

import until from 'untiljs'

// Wait for value to equal something
let count = 0
setTimeout(() => {
  count = 5
}, 1000)
await until(() => count).toBe(5)

// Wait for custom condition
await until(() => count).toMatch(v => v > 3)

// Wait for truthy value
let data = null
setTimeout(() => {
  data = { name: 'John' }
}, 500)
await until(() => data).toBeTruthy()

// Wait for value to change
let value = 'initial'
setTimeout(() => {
  value = 'changed'
}, 1000)
await until(() => value).changed()

// Wait for multiple changes
await until(() => value).changedTimes(3)

Vue 3 Integration

Vue refs work directly because they are RefLike objects:

import { ref, computed } from 'vue'
import until from 'untiljs'

const count = ref(0)
const doubled = computed(() => count.value * 2)

// Method 1: Pass ref directly (recommended for Vue!)
async function waitForValue() {
  await until(count).toBe(5)
  console.log('Count reached 5!')
}

// Method 2: Use getter function
async function waitForValueGetter() {
  await until(() => count.value).toBe(5)
  console.log('Count reached 5!')
}

// Watch computed values
async function waitForDoubled() {
  await until(doubled).toBe(10)
  console.log('Doubled value reached 10!')
}

// Deep comparison for objects
const user = ref({ profile: { name: '' } })
setTimeout(() => {
  user.value = { profile: { name: 'John' } }
}, 1000)

await until(user).toMatch(v => v.profile.name === 'John', { deep: true })

React Integration

Important: Due to React's closure behavior, using until(() => stateValue) directly won't work properly. Use useRef or a custom hook instead.

❌ Wrong Way (Won't Work)

// This won't detect changes due to React's closure behavior
const [value, setValue] = useState(0)
await until(() => value).toBe(5) // ❌ Always sees old value

✅ Correct Way: useUntil Hook

import { useCallback, useRef, useState } from 'react'
import until from 'untiljs'

// Custom hook for untiljs
function useUntil<T>(initialValue: T) {
  const ref = useRef(initialValue)
  const [value, setValue] = useState(initialValue)

  const refLike = useRef({
    get value() {
      return ref.current
    },
    set value(newValue: T) {
      ref.current = newValue
      setValue(newValue)
    }
  })

  const setValueAndRef = useCallback((newValue: T) => {
    ref.current = newValue
    setValue(newValue)
  }, [])

  return {
    value,
    setValue: setValueAndRef,
    until: () => until(refLike.current)
  }
}

// Usage
function MyComponent() {
  const data = useUntil(0)

  const handleClick = async () => {
    data.setValue(0)
    setTimeout(() => data.setValue(5), 1000)
    await data.until().toBe(5) // ✅ Works correctly!
    console.log('Value is now 5!')
  }

  return <button onClick={handleClick}>Test</button>
}

✅ Alternative: Subscribable Store

function createSubscribable<T>(initialValue: T) {
  let value = initialValue
  const listeners = new Set<(value: T) => void>()

  return {
    get value() {
      return value
    },
    set value(newValue: T) {
      value = newValue
      listeners.forEach(l => l(value))
    },
    subscribe(callback: (value: T) => void) {
      listeners.add(callback)
      callback(value)
      return () => listeners.delete(callback)
    }
  }
}

// Usage with useEffect
const store = createSubscribable(0)
await until(store).toBe(5) // ✅ Most efficient!

✅ Built-in Solution: createStore

untiljs v2.1+ provides a built-in createStore function for React:

import { createStore } from 'untiljs'
import until from 'untiljs'

// Create store outside component or in useRef
const store = createStore(0)

function MyComponent() {
  const [value, setValue] = useState(store.value)

  useEffect(() => store.subscribe(setValue), [])

  const handleClick = async () => {
    store.value = 5
    await until(store).toBe(5) // ✅ Clean and efficient!
  }

  return <button onClick={handleClick}>Test</button>
}

Angular Integration

Angular 19+ signals work with getter functions:

import { Component, signal } from '@angular/core'
import until from 'untiljs'

@Component({
  selector: 'app-example',
  template: `
    <p>Count: {{ count() }}</p>
    <button (click)="waitForValue()">Wait for 5</button>
  `
})
export class ExampleComponent {
  count = signal(0)

  async waitForValue() {
    this.count.set(0)
    setTimeout(() => this.count.set(5), 1000)

    // Use getter function with signals
    await until(() => this.count()).toBe(5)
    console.log('Count reached 5!')
  }
}

Using createStore in Angular

import { Component } from '@angular/core'
import { createStore } from 'untiljs'
import until from 'untiljs'

@Component({
  selector: 'app-example',
  template: `<button (click)="test()">Test</button>`
})
export class ExampleComponent {
  private store = createStore(0)

  async test() {
    this.store.value = 0
    setTimeout(() => this.store.value = 5, 1000)

    // Store works directly with until
    await until(this.store).toBe(5)
  }
}

Svelte Integration

Svelte 5 runes work with getter functions:

<script>
  import until from 'untiljs'

  let count = $state(0)

  async function waitForValue() {
    count = 0
    setTimeout(() => count = 5, 1000)

    // Use getter function with $state
    await until(() => count).toBe(5)
    console.log('Count reached 5!')
  }
</script>

<p>Count: {count}</p>
<button onclick={waitForValue}>Wait for 5</button>

Using createStore in Svelte

<script>
  import { createStore } from 'untiljs'
  import until from 'untiljs'

  const store = createStore(0)
  let storeValue = $state(store.value)

  // Subscribe to changes
  $effect(() => {
    return store.subscribe(value => storeValue = value)
  })

  async function test() {
    store.value = 0
    setTimeout(() => store.value = 5, 1000)
    await until(store).toBe(5)
  }
</script>

Vue 2 Integration

Vue 2.7+ supports Composition API natively:

<script>
import { ref } from 'vue'
import until from 'untiljs'

export default {
  setup() {
    const count = ref(0)

    const waitForValue = async () => {
      count.value = 0
      setTimeout(() => count.value = 5, 1000)

      // Use getter function with ref
      await until(() => count.value).toBe(5)
      console.log('Count reached 5!')
    }

    return { count, waitForValue }
  }
}
</script>

<template>
  <p>Count: {{ count }}</p>
  <button @click="waitForValue">Wait for 5</button>
</template>

Note: Vue 2.6 and below require @vue/composition-api plugin. Import from @vue/composition-api instead of vue.

RxJS Integration

import { BehaviorSubject } from 'rxjs'
import until from 'untiljs'

// Convert BehaviorSubject to Subscribable
const subject = new BehaviorSubject(1)

const subscribable = {
  get value() {
    return subject.value
  },
  subscribe(callback: (value: number) => void) {
    const subscription = subject.subscribe(callback)
    return () => subscription.unsubscribe()
  }
}

await until(subscribable).toBe(2)

// Or use getter function
await until(() => subject.value).toBe(2)

Node.js Usage

import until from 'untiljs'
import { EventEmitter } from 'events'

// Wait for event-based state changes
const emitter = new EventEmitter()
let status = 'pending'

emitter.on('ready', () => {
  status = 'ready'
})

setTimeout(() => emitter.emit('ready'), 1000)
await until(() => status).toBe('ready')

// Wait for file changes (with fs.watch)
import fs from 'fs'
import { readFile } from 'fs/promises'

let fileContent = await readFile('./data.txt', 'utf-8')
const watcher = fs.watch('./data.txt', async () => {
  fileContent = await readFile('./data.txt', 'utf-8')
})

await until(() => fileContent).toMatch(content => content.includes('target'))
watcher.close()

Array Methods

import until from 'untiljs'

// Wait for array to contain value
let items = ['apple', 'banana']
setTimeout(() => {
  items.push('orange')
}, 500)

await until(() => items).toContains('orange')

// Arrays also support all value methods
let numbers = [1, 2, 3]
setTimeout(() => {
  numbers = [1, 2, 3, 4, 5]
}, 500)

await until(() => numbers).toBe([1, 2, 3, 4, 5], { deep: true })
await until(() => numbers).toMatch(arr => arr.length >= 5)

Timeout Handling

import until from 'untiljs'

// Timeout without throwOnTimeout - returns current value
let value = 0
const result = await until(() => value).toBe(5, { timeout: 1000 })
console.log(result) // 0 (current value after timeout)

// Timeout with throwOnTimeout - rejects promise
try {
  await until(() => value).toBe(5, { timeout: 1000, throwOnTimeout: true })
} catch (error) {
  console.error('Timeout!', error)
}

Not Modifier

import until from 'untiljs'

// Wait until value is NOT 5
let value = 5
setTimeout(() => {
  value = 10
}, 500)

await until(() => value).not.toBe(5)
console.log(value) // 10

// Wait until value is NOT null
let data = null
setTimeout(() => {
  data = 'loaded'
}, 500)

await until(() => data).not.toBeNull()
console.log(data) // 'loaded'

Deep Comparison

import until from 'untiljs'

// Compare nested objects
let config = { server: { port: 3000 } }
setTimeout(() => {
  config = { server: { port: 8080 } }
}, 500)

await until(() => config).toBe({ server: { port: 8080 } }, { deep: true })

// Limit comparison depth
await until(() => config).toBe(
  { server: { port: 8080 } },
  { deep: 2 } // Compare up to 2 levels deep
)

Migration Guide (v1.x → v2.x)

Breaking Changes

  1. No longer requires @vue/reactivity - The library is now framework-agnostic
  2. Vue refs work directly - Pass ref directly or use () => ref.value
  3. Removed flush option - This was Vue-specific and has no generic equivalent
  4. deep option now accepts boolean | number - More flexible depth control

Quick Migration

// v1.x
import { ref } from 'vue'
import until from 'untiljs'

const count = ref(0)
await until(count).toBe(5)

// v2.x - Same code still works!
import { ref } from 'vue'
import until from 'untiljs'

const count = ref(0)
await until(count).toBe(5) // Direct ref usage still supported
// OR
await until(() => count.value).toBe(5) // Getter function also works

Comparison Table

v1.x v2.x
until(ref) until(ref) (still works!) or until(() => ref.value)
until(ref).toBe(otherRef) until(ref).toBe(otherRef.value)
{ flush: 'sync' } (removed - uses requestAnimationFrame/setImmediate)
{ deep: true } { deep: true } (unchanged) or { deep: 5 } for depth limit

Using unpkg CDN

<script src="https://unpkg.com/untiljs@latest/dist/index.iife.min.js"></script>
<script>
  let value = 0
  setTimeout(() => {
    value = 5
  }, 1000)

  until(() => value)
    .toBe(5)
    .then(() => {
      console.log('Value is 5!')
    })
</script>

Browser Support

Browser Version
Chrome Latest 2 versions
Firefox Latest 2 versions
Safari Latest 2 versions
Edge Latest 2 versions
Node.js >= 16

Support & Issues

Please open an issue here.

License

MIT

About

Promised one-time watch for changes

Topics

Resources

License

Stars

Watchers

Forks

Sponsor this project

Packages

 
 
 

Contributors