Skip to content

Instantly share code, notes, and snippets.

@renoirb
Created July 15, 2021 00:33
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save renoirb/400c50986eaefd9b9b8936d44a6e670b to your computer and use it in GitHub Desktop.
Save renoirb/400c50986eaefd9b9b8936d44a6e670b to your computer and use it in GitHub Desktop.
Stateful purgatory

Stateful purgatory

Keep track of strings or numbers, and tell me whenever they are no longer needed.

Make something we want to keep track of to be self-purging. i.e. keep track of keys, by telling you've used them, if after a time isn't used, fill a list

Based on debounce-with-map.

Example basic

import { StatefulPurgatory } from './stateful-purgatory'

const state = new StatefulPurgatory()

// What should be called when the probe found expired keys
const maybePurge = (expiredKeys: string[] = []) => {
  if (expiredKeys.length > 0) {
    // Make a call and use expiredKeys list as argument!
  }
}
// The probe we use so we can watch
const probe = (expiredKeys) => {
  console.log('debounce: ', key);
  maybePurge(state.expired)
}
// Set the thing we want to run for checks
state.setDebounce(probe, 5000)

state.yup('key1')
state.yup('key1')
state.yup('key2')
state.yup('key1')

/**
* After 5 seconds of last keyed-called function output will be:
*
* debounce:  key2
* debounce:  key1
*/
import { StatefulPurgatory } from './stateful-purgatory'
const PROBE_TIMEOUT_TIME = 1000
const EXAMPLE_KEYS = ['foo', 'bar', 'baz'] as const
// https://jestjs.io/docs/next/timer-mocks
// https://github.com/facebook/jest/blob/fdc74af3/examples/timer/__tests__/infinite_timer_game.test.js
const createDomTree = (id: string, datasetKey?: string): HTMLDivElement => {
const button = document.createElement('button')
button.textContent = 'Click Me!'
if (datasetKey) {
button.dataset.key = datasetKey
}
const rootEl = document.createElement('div')
rootEl.setAttribute('id', id)
rootEl.appendChild(button)
return rootEl
}
describe('common/stateful-purgatory', () => {
beforeAll(() => {
jest.useFakeTimers()
})
afterAll(() => {
jest.useRealTimers()
})
describe('internal state', () => {
let state: StatefulPurgatory<string>
beforeEach(() => {
state = new StatefulPurgatory()
})
describe('coordinating with DOM', () => {
type PleasePurge = { expired: string[] }
const EXPECTED_KEY = EXAMPLE_KEYS[0] // foo
const tree = createDomTree('alpha', EXPECTED_KEY)
document.body.appendChild(tree)
const spyPleasePurgeEvent = jest.fn()
const pleasePurgeEventListener = (event: Event) => {
if ('detail' in event) {
const detail = (event as CustomEvent<PleasePurge>).detail
spyPleasePurgeEvent(detail.expired)
}
}
const probe = jest.fn((recv: Set<string>) => {
const expired = [...recv]
document.dispatchEvent(new CustomEvent<PleasePurge>('please-purge', { detail: { expired } }))
return expired
})
const clickHandler = (event: HTMLElementEventMap['click']) => {
if (event.target) {
const dataset = (event.target as HTMLButtonElement).dataset
const { key } = dataset // data-key="foo"
state.yup(key)
}
}
beforeEach(() => {
state.setDebounce(probe, PROBE_TIMEOUT_TIME)
tree.querySelector('button').addEventListener('click', clickHandler)
jest.advanceTimersByTime(PROBE_TIMEOUT_TIME - 100)
})
afterEach(() => {
spyPleasePurgeEvent.mockReset()
})
beforeAll(() => {
document.addEventListener('please-purge', pleasePurgeEventListener)
})
afterAll(() => {
document.removeEventListener('please-purge', pleasePurgeEventListener)
})
test('clicking on an element', () => {
tree.querySelector('button').click()
tree.querySelector('button').click()
jest.advanceTimersByTime(10)
tree.querySelector('button').click()
jest.advanceTimersByTime(PROBE_TIMEOUT_TIME)
expect(state.expired).toHaveLength(1)
expect(probe).toHaveBeenNthCalledWith(1, expect.objectContaining(new Set([EXPECTED_KEY])))
expect(spyPleasePurgeEvent).toHaveBeenNthCalledWith(1, expect.objectContaining([EXPECTED_KEY]))
})
})
describe('expiration', () => {
// The probe we use so we can watch
const probe = jest.fn()
beforeEach(() => {
state.setDebounce(probe, PROBE_TIMEOUT_TIME)
// Track a few keys
EXAMPLE_KEYS.forEach(k => state.yup(k))
jest.advanceTimersByTime(PROBE_TIMEOUT_TIME - 100)
})
afterEach(() => {
probe.mockReset()
})
test('keys are not expired before timeout', () => {
expect(state.expired).toHaveLength(0)
expect(probe).not.toHaveBeenCalled()
})
test('after timeout time, keys apears in expired list', () => {
jest.advanceTimersByTime(100)
expect(state.expired).toHaveLength(EXAMPLE_KEYS.length)
expect(probe).toHaveBeenNthCalledWith(1, expect.objectContaining(new Set([...EXAMPLE_KEYS])))
})
test('after timeout time, only keys not called becomes expired', () => {
// Turns out that all, except the first one we want to omit
const stillBeingWatchedItems = EXAMPLE_KEYS.slice(1)
// Poke only the rest, omit only one ^ (the first)
stillBeingWatchedItems.forEach(k => state.yup(k)) // bar,bazz
jest.advanceTimersByTime(100)
expect(state.expired).toMatchObject([EXAMPLE_KEYS[0]]) // foo
expect(probe).toHaveBeenNthCalledWith(1, expect.objectContaining(new Set([EXAMPLE_KEYS[0]])))
})
})
})
})
const defaultKeyValidator = (key: unknown): boolean => typeof key === 'string' || typeof key === 'number'
export type StatefulPurgatoryKeyValidator = typeof defaultKeyValidator
export class StatefulPurgatory<T> {
readonly #timerMap = new Map<T, number>()
readonly #expiredKeys = new Set<T>()
get expired(): T[] {
return [...this.#expiredKeys]
}
/**
* Method to call regularly to make sure it stays in the time.
* This keys keeps track of when a key is still being used, and keeps them in timerMap.
* If a key in the timerMap is not called at the timeout from setDebounce, it'll be moved into expired.
*/
#yup?: (key: T) => void
constructor(public readonly keyValidator: StatefulPurgatoryKeyValidator = defaultKeyValidator) {
Object.defineProperty(this, '__expired', { value: this.#expiredKeys, enumerable: true, configurable: false })
Object.defineProperty(this, '__timerMap', { value: this.#timerMap, enumerable: true, configurable: false })
}
/**
* Method to keep calling around.
*
* Naming debate: ... yup that thing's still needed. Name TBD.
*/
public yup(key: T): void {
if (!this.#yup) {
throw new Error(`Missing required setup step, did you use setDebounce on that instance?`)
}
this.#yup(key)
}
/**
* Make something we want to keep track of to be self-purging.
* I.e. keep track of keys, by telling you've used them, if after a time isn't used, fill a list
*
* The first argument of this method is a function we can call to do cleanup, and from it we can ask
* members expired.
*
* Thank you: https://github.com/kricha/debounce-with-map
*/
public setDebounce = (fn: Exclude<TimerHandler, string>, delay = 1000) => {
if (this.#yup) {
throw new Error(`It is best NOT to set this method twice`)
}
this.#yup = (key: T): void => {
if (!key) {
throw Error('You need to set a key')
}
if (this.keyValidator(key) === false) {
throw Error(
`This key "${key}" did not match key format validation, refer to this instance’s keyValidator method`,
)
}
;((...args) => {
// Start by checking if there's still an item in the #timerMap
// i.e. something that we're still checking, we're going to check, right now.
let timeOutId: number = this.#timerMap.get(key)
if (timeOutId) {
// We’ll set another timeoutId for that one, carry on.
window.clearTimeout(timeOutId)
}
// If it was expired but hasn't been cleaned up yet
// Remove it it is no longer expired
this.#expiredKeys.delete(key)
// !! --- Trigger warning, use of window.something --- !!
// If we don't set window.setTimeout, it would use NodeJS’
// In the case of setTimeout would return NodeJS.Timeout
timeOutId = window.setTimeout(() => {
// Call that function that'll do the check, it might need to be purged.
// Stop tracking it
this.#timerMap.delete(key)
// Add it to expired keys (i.e. id’s we no longer need in use or tracking)
this.#expiredKeys.add(key)
// This function will be used as a ticker to run self-cleanup things
// That's why it is at the end
/** @TODO Error handling and purging only if worked */
fn?.(this.#expiredKeys, ...args)
// this.#expiredKeys.clear()
}, delay)
this.#timerMap.set(key, timeOutId)
})()
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment