diff --git a/src/elements/play-pen/play-pen.ts b/src/elements/play-pen/play-pen.ts index 577b727..26c6dc5 100644 --- a/src/elements/play-pen/play-pen.ts +++ b/src/elements/play-pen/play-pen.ts @@ -1,5 +1,4 @@ import type {Empty, LinkedBundle} from '@devvit/protos' -import {throttle} from '@devvit/shared-types/throttle.js' import type {DevvitUIError} from '@devvit/ui-renderer/client/devvit-custom-post.js' import type {VirtualTypeScriptEnvironment} from '@typescript/vfs' import { @@ -57,6 +56,7 @@ import { emptyAssetsState, PlayAssets } from '../play-assets/play-assets.js' +import {debounce, type DebounceCleanupFn} from '../../utils/debounce.js' declare global { interface HTMLElementTagNameMap { @@ -74,9 +74,7 @@ declare global { export class PlayPen extends LitElement { static override readonly styles: CSSResultGroup = css` ${cssReset} - ${unsafeCSS(penVars)} - :host { /* Light mode. */ color: var(--color-foreground); @@ -118,6 +116,7 @@ export class PlayPen extends LitElement { } /* Makes dropdowns appear over other content */ + play-pen-header, play-pen-footer { z-index: var(--z-menu); @@ -181,6 +180,8 @@ export class PlayPen extends LitElement { private _src: string | undefined #template?: boolean + #cleanupDelayedSideEffects: DebounceCleanupFn = () => {} + override connectedCallback(): void { super.connectedCallback() @@ -218,6 +219,11 @@ export class PlayPen extends LitElement { this.#setName(pen.name, false) } + override disconnectedCallback() { + this.#cleanupDelayedSideEffects() + super.disconnectedCallback() + } + protected override render(): TemplateResult { return html` { + /** Debounced changes after updating sources. */ + #setSrcSideEffects = debounce((save: boolean): void => { this.#version++ this._bundle = link( compile(this.#env), diff --git a/src/utils/debounce.test.ts b/src/utils/debounce.test.ts new file mode 100644 index 0000000..a7407fb --- /dev/null +++ b/src/utils/debounce.test.ts @@ -0,0 +1,45 @@ +import {debounce} from './debounce.js' +import {expect, it, describe, vi} from 'vitest' + +describe('debounce', () => { + it('executes with a delay', async () => { + const out = {val: 0} + const fn = debounce((val: number) => (out.val = val), 100) + fn(1) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(out.val).toBe(0) + await new Promise(resolve => setTimeout(resolve, 100)) + expect(out.val).toBe(1) + }) + + it('executes the last call of a series', async () => { + const out = {val: 0} + const fn = debounce((val: number) => (out.val = val), 100) + fn(1) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(out.val).toBe(0) + + fn(2) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(out.val).toBe(0) + + fn(3) + await new Promise(resolve => setTimeout(resolve, 100)) + expect(out.val).toBe(3) + + fn(4) + await new Promise(resolve => setTimeout(resolve, 100)) + expect(out.val).toBe(4) + }) + + it('has a cleanup function that cancels the delayed execution', async () => { + const innerFn = vi.fn() + const debouncedFn = debounce(innerFn, 1000) + const cleanupFn = debouncedFn(1) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(innerFn).toBeCalledTimes(0) + cleanupFn() + await new Promise(resolve => setTimeout(resolve, 1000)) + expect(innerFn).toBeCalledTimes(0) + }) +}) diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts new file mode 100644 index 0000000..7ad5a49 --- /dev/null +++ b/src/utils/debounce.ts @@ -0,0 +1,18 @@ +/** Delay function execution until after the invocations stopped. */ +export type DebounceCleanupFn = () => void + +export const debounce = ( + fn: (...args: T) => void, + period: number +): ((...args: T) => DebounceCleanupFn) => { + let timeout: ReturnType | undefined + return (...args: T) => { + clearTimeout(timeout) + timeout = setTimeout(() => { + fn(...args) + }, period) + return () => { + clearTimeout(timeout) + } + } +}