diff --git a/package.json b/package.json index f1d592d8..1437045d 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,11 @@ "url": "https://github.com/gravity-ui/graph" }, "scripts": { - "typecheck": "pnpm --filter @gravity-ui/graph run typecheck && pnpm --filter @gravity-ui/graph-storybook run typecheck && pnpm --filter @gravity-ui/graph-e2e run typecheck", + "typecheck": "pnpm --filter @gravity-ui/graph-scheduler run typecheck && pnpm --filter @gravity-ui/graph run typecheck && pnpm --filter @gravity-ui/graph-storybook run typecheck && pnpm --filter @gravity-ui/graph-e2e run typecheck", "typecheck:published-playwright-types": "pnpm --filter @gravity-ui/graph run typecheck:published-playwright-types", - "lint": "pnpm --filter @gravity-ui/graph run lint && pnpm --filter @gravity-ui/graph-storybook run lint", - "test:unit": "pnpm --filter @gravity-ui/graph run test:unit", - "test": "pnpm --filter @gravity-ui/graph run test", + "lint": "pnpm --filter \"./packages/*\" run lint && pnpm --filter @gravity-ui/graph-storybook run lint", + "test:unit": "pnpm --filter \"./packages/*\" run test:unit", + "test": "pnpm --filter \"./packages/*\" run test", "storybook": "pnpm --filter @gravity-ui/graph run build && pnpm --filter @gravity-ui/graph-storybook run storybook", "build-storybook": "pnpm --filter @gravity-ui/graph run build && pnpm --filter @gravity-ui/graph-storybook run build-storybook", "build:docs": "pnpm --filter @gravity-ui/graph run build:docs", diff --git a/packages/graph/package.json b/packages/graph/package.json index c8aecc02..a6119acb 100644 --- a/packages/graph/package.json +++ b/packages/graph/package.json @@ -72,7 +72,7 @@ "build:docs": "node scripts/build-docs.mjs", "build": "node scripts/build.mjs", "prepublishOnly": "pnpm run typecheck && pnpm run test", - "dev": "chokidar \"src/**/*\" -c \"pnpm run build\" --initial", + "dev": "chokidar \"src/**/*\" \"../scheduler/src/**/*\" -c \"pnpm run build\" --initial", "test:package-contract": "node tests/package-contract/run.mjs" }, "peerDependencies": { @@ -104,6 +104,7 @@ "@commitlint/cli": "^19.5.0", "@commitlint/config-conventional": "^19.5.0", "@gravity-ui/eslint-config": "^3.2.0", + "@gravity-ui/graph-scheduler": "workspace:*", "@gravity-ui/prettier-config": "^1.1.0", "@gravity-ui/readme-validator": "^1.3.0", "@gravity-ui/tsconfig": "^1.0.0", diff --git a/packages/graph/scripts/build.mjs b/packages/graph/scripts/build.mjs index 27d32a24..4f3c95b2 100644 --- a/packages/graph/scripts/build.mjs +++ b/packages/graph/scripts/build.mjs @@ -8,6 +8,9 @@ import { build } from "esbuild"; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const buildDirectory = path.join(packageRoot, "build"); const manifest = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8")); +const inlinedWorkspacePackages = new Map([ + ["@gravity-ui/graph-scheduler", fileURLToPath(import.meta.resolve("@gravity-ui/graph-scheduler"))], +]); const externalPackages = [ ...new Set([...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {})]), ]; @@ -64,7 +67,9 @@ function assertNoBundledPackages(results) { const [firstSegment, secondSegment] = packagePath.split("/"); const packageName = firstSegment.startsWith("@") ? `${firstSegment}/${secondSegment}` : firstSegment; - bundledPackages.add(packageName); + if (!inlinedWorkspacePackages.has(packageName)) { + bundledPackages.add(packageName); + } } } } @@ -79,6 +84,42 @@ function assertNoBundledPackages(results) { } } +async function assertInlinedWorkspacePackages(results) { + const bundledInputs = new Set( + results.flatMap((result) => Object.keys(result.metafile.inputs).map((input) => path.resolve(packageRoot, input))) + ); + + for (const [packageName, entryPath] of inlinedWorkspacePackages) { + const workspaceSpecifier = manifest.devDependencies?.[packageName]; + const workspacePackageRoot = path.resolve(path.dirname(entryPath), ".."); + const workspaceManifest = JSON.parse(await readFile(path.join(workspacePackageRoot, "package.json"), "utf8")); + + if (typeof workspaceSpecifier !== "string" || !workspaceSpecifier.startsWith("workspace:")) { + throw new Error(`${packageName} must be an explicit workspace devDependency of ${manifest.name}.`); + } + + if (workspaceManifest.name !== packageName || workspaceManifest.private !== true) { + throw new Error(`${packageName} must resolve to a private workspace package.`); + } + + if (!bundledInputs.has(entryPath)) { + throw new Error(`Production bundles do not inline the private workspace package ${packageName}.`); + } + + for (const result of results) { + for (const output of Object.values(result.metafile.outputs)) { + const unresolvedImport = output.imports.find( + ({ path: importPath }) => importPath === packageName || importPath.startsWith(`${packageName}/`) + ); + + if (unresolvedImport) { + throw new Error(`Production bundles contain an unresolved private import of ${packageName}.`); + } + } + } + } +} + async function pathExists(filePath) { try { await access(filePath); @@ -244,7 +285,10 @@ const [browserResult, playwrightEsmResult, playwrightCjsResult, stylesResult] = }), ]); -assertNoBundledPackages([browserResult, playwrightEsmResult, playwrightCjsResult, stylesResult]); +const buildResults = [browserResult, playwrightEsmResult, playwrightCjsResult, stylesResult]; + +await assertInlinedWorkspacePackages(buildResults); +assertNoBundledPackages(buildResults); const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; await run(pnpmCommand, ["exec", "tsc", "-p", "tsconfig.publish.json"]); diff --git a/packages/graph/src/lib/Scheduler.ts b/packages/graph/src/lib/Scheduler.ts index ed4742c7..64545157 100644 --- a/packages/graph/src/lib/Scheduler.ts +++ b/packages/graph/src/lib/Scheduler.ts @@ -1,9 +1,11 @@ -import { Tree } from "./Tree"; +import { + GlobalScheduler as PrivateGlobalScheduler, + Scheduler as PrivateScheduler, + globalScheduler as privateGlobalScheduler, + scheduler as privateScheduler, +} from "@gravity-ui/graph-scheduler"; -const rAF: Function = typeof window !== "undefined" ? window.requestAnimationFrame : (fn) => global.setTimeout(fn, 16); -const cAF: Function = typeof window !== "undefined" ? window.cancelAnimationFrame : global.clearTimeout; -const getNow = - typeof window !== "undefined" ? window.performance.now.bind(window.performance) : global.Date.now.bind(global.Date); +import { Tree } from "./Tree"; interface IScheduler { performUpdate: (time: number) => void; @@ -16,160 +18,38 @@ export enum ESchedulerPriority { LOW = 3, LOWEST = 4, } -export class GlobalScheduler { - private schedulers: [IScheduler[], IScheduler[], IScheduler[], IScheduler[], IScheduler[]]; - private _cAFID: number; - private toRemove: Array<[IScheduler, ESchedulerPriority]> = []; - private visibilityChangeHandler: (() => void) | null = null; - - constructor() { - this.tick = this.tick.bind(this); - this.handleVisibilityChange = this.handleVisibilityChange.bind(this); - - this.schedulers = [[], [], [], [], []]; - this.setupVisibilityListener(); - } - - /** - * Setup listener for page visibility changes. - * When tab becomes visible after being hidden, force immediate update. - * This fixes the issue where tabs opened in background don't render HTML until interaction. - */ - private setupVisibilityListener(): void { - if (typeof document === "undefined") { - return; // Not in browser environment - } - - this.visibilityChangeHandler = this.handleVisibilityChange; - document.addEventListener("visibilitychange", this.visibilityChangeHandler); - } - - /** - * Handle page visibility changes. - * When page becomes visible, perform immediate update if scheduler is running. - */ - private handleVisibilityChange(): void { - // Only update if page becomes visible and scheduler is running - if (!document.hidden && this._cAFID) { - // Perform immediate update when tab becomes visible - this.performUpdate(); - } - } - - /** - * Cleanup visibility listener - */ - private cleanupVisibilityListener(): void { - if (this.visibilityChangeHandler && typeof document !== "undefined") { - document.removeEventListener("visibilitychange", this.visibilityChangeHandler); - this.visibilityChangeHandler = null; - } - } - - public getSchedulers() { - return this.schedulers; - } - - public addScheduler(scheduler: IScheduler, index = ESchedulerPriority.MEDIUM) { - this.schedulers[index].push(scheduler); - return () => this.removeScheduler(scheduler, index); - } - - public removeScheduler(scheduler: IScheduler, index = ESchedulerPriority.MEDIUM) { - this.toRemove.push([scheduler, index]); - } - - public start() { - if (!this._cAFID) { - this._cAFID = rAF(this.tick); - } - } - - public stop() { - cAF(this._cAFID); - this._cAFID = undefined; - } - - /** - * Cleanup method to be called when GlobalScheduler is no longer needed. - * Stops the scheduler and removes event listeners. - */ - public destroy(): void { - this.stop(); - this.cleanupVisibilityListener(); - } - - public tick() { - this.performUpdate(); - this._cAFID = rAF(this.tick); - } - public performUpdate() { - const startTime = getNow(); - let schedulers: IScheduler[] = []; - - for (let i = 0; i < this.schedulers.length; i += 1) { - schedulers = this.schedulers[i]; - - for (let j = 0; j < schedulers.length; j += 1) { - schedulers[j].performUpdate(getNow() - startTime); - } - } - - // Process deferred removals after all schedulers have been executed - for (const [scheduler, index] of this.toRemove) { - const schedulerIndex = this.schedulers[index].indexOf(scheduler); - if (schedulerIndex !== -1) { - this.schedulers[index].splice(schedulerIndex, 1); - } - } - this.toRemove.length = 0; - } +export interface GlobalScheduler { + getSchedulers(): [IScheduler[], IScheduler[], IScheduler[], IScheduler[], IScheduler[]]; + addScheduler(scheduler: IScheduler, index?: ESchedulerPriority): () => void; + removeScheduler(scheduler: IScheduler, index?: ESchedulerPriority): void; + start(): void; + stop(): void; + destroy(): void; + tick(): void; + performUpdate(): void; } -export const globalScheduler = new GlobalScheduler(); - -export const scheduler = globalScheduler; -export class Scheduler { - private sheduled: boolean; - private root: Tree; - - constructor() { - this.performUpdate = this.performUpdate.bind(this); - - this.sheduled = false; - - globalScheduler.addScheduler(this); - } - - public setRoot(root: Tree) { - this.root = root; - } - - public start() { - globalScheduler.addScheduler(this); - } - - public stop() { - globalScheduler.removeScheduler(this); - } - - public update() { - this.root?.traverseDown(this.iterator); - } - - public iterator(node: Tree) { - return node.data.iterate(); - } +type TGlobalSchedulerConstructor = new () => GlobalScheduler; + +// The interface keeps declarations graph-owned while the constructor value delegates to the private package. +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const GlobalScheduler = PrivateGlobalScheduler as unknown as TGlobalSchedulerConstructor; +export const globalScheduler = privateGlobalScheduler as unknown as GlobalScheduler; +export const scheduler = privateScheduler as unknown as GlobalScheduler; + +export interface Scheduler { + setRoot(root: Tree): void; + start(): void; + stop(): void; + update(): void; + iterator(node: Tree): boolean; + scheduleUpdate(): void; + performUpdate(): void; +} - public scheduleUpdate() { - this.sheduled = true; - } +type TSchedulerConstructor = new () => Scheduler; - public performUpdate() { - if (this.sheduled) { - this.sheduled = false; - this.update(); - } - } -} +// The interface keeps declarations graph-owned while the constructor value delegates to the private package. +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const Scheduler = PrivateScheduler as unknown as TSchedulerConstructor; diff --git a/packages/graph/src/utils/utils/schedule.test.ts b/packages/graph/src/utils/utils/schedule.test.ts deleted file mode 100644 index 2b9c6109..00000000 --- a/packages/graph/src/utils/utils/schedule.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ESchedulerPriority, scheduler } from "../../lib"; - -import { debounce } from "./schedule"; - -describe("debounce.flush", () => { - beforeEach(() => { - jest.useFakeTimers(); - scheduler.start(); - }); - - afterEach(() => { - scheduler.stop(); - jest.useRealTimers(); - }); - - it("keeps pending schedule when flush callback re-schedules", () => { - type TDebounced = ReturnType void>>; - const debouncedRef: { fn: TDebounced | null } = { fn: null }; - - const mockFn = jest.fn((arg: string) => { - if (arg === "initial") { - debouncedRef.fn?.("follow-up"); - } - }); - - debouncedRef.fn = debounce(mockFn, { - frameInterval: 1, - frameTimeout: 0, - priority: ESchedulerPriority.LOWEST, - }); - const debouncedFn = debouncedRef.fn; - - debouncedFn("initial"); - debouncedFn.flush(); - - expect(mockFn).toHaveBeenCalledTimes(1); - expect(mockFn).toHaveBeenCalledWith("initial"); - // Old flush() called cancel() after fn(), wiping this re-schedule. - expect(debouncedFn.isScheduled()).toBe(true); - }); -}); diff --git a/packages/graph/src/utils/utils/schedule.ts b/packages/graph/src/utils/utils/schedule.ts index 6851d64a..04e3e116 100644 --- a/packages/graph/src/utils/utils/schedule.ts +++ b/packages/graph/src/utils/utils/schedule.ts @@ -1,10 +1,10 @@ -import { ESchedulerPriority, scheduler } from "../../lib"; +import { + debounce as privateDebounce, + schedule as privateSchedule, + throttle as privateThrottle, +} from "@gravity-ui/graph-scheduler"; -// Helper to get current time (similar to scheduler implementation) -const getNow = - typeof globalThis !== "undefined" - ? globalThis.performance.now.bind(globalThis.performance) - : global.Date.now.bind(global.Date); +import { ESchedulerPriority } from "../../lib"; export type TScheduleOptions = { priority: ESchedulerPriority; @@ -12,29 +12,7 @@ export type TScheduleOptions = { once?: boolean; }; -export const schedule = (fn: Function, options: TScheduleOptions) => { - const { priority, frameInterval, once } = options; - let frameCounter = 0; - let isRemoved = false; - const debounceScheduler = { - performUpdate: () => { - frameCounter++; - if (frameCounter >= frameInterval) { - if (once && !isRemoved) { - scheduler.removeScheduler(debounceScheduler, priority); - isRemoved = true; - } - fn(); - frameCounter = 0; - if (once) { - isRemoved = true; - scheduler.removeScheduler(debounceScheduler, priority); - } - } - }, - }; - return scheduler.addScheduler(debounceScheduler, priority); -}; +export const schedule = privateSchedule as unknown as (fn: Function, options: TScheduleOptions) => () => void; export type TDebounceOptions = { priority?: ESchedulerPriority; @@ -42,265 +20,12 @@ export type TDebounceOptions = { frameTimeout?: number; }; -/** - * Creates a debounced function that delays execution until after frameInterval frames - * and frameTimeout milliseconds have passed since it was last invoked. - * Both conditions must be met for execution. - * @param fn - The function to debounce - * @param options - Configuration options - * @param options.priority - Scheduler priority (default: MEDIUM) - * @param options.frameInterval - Number of frames to wait before execution (default: 1) - * @param options.frameTimeout - Minimum time in milliseconds to wait before execution (default: 0) - * @returns A debounced version of the function with cancel and flush methods - */ -export const debounce = ) => void>( +export const debounce = privateDebounce as unknown as ) => void>( fn: T, - { priority = ESchedulerPriority.MEDIUM, frameInterval = 1, frameTimeout = 0 }: TDebounceOptions = {} -): T & { cancel: () => void; flush: () => void; isScheduled: () => boolean } => { - let frameCounter = 0; - let isScheduled = false; - let cancelled = false; - let removeScheduler: (() => void) | null = null; - let latestArgs: Parameters | undefined; - let startTime = 0; - - const debouncedScheduler = { - performUpdate: () => { - // cancel() was called before this frame's performUpdate ran — skip execution - if (cancelled) { - cancelled = false; - return; - } - frameCounter++; - const currentTime = getNow(); - const elapsedTime = currentTime - startTime; - - if (frameCounter >= frameInterval && elapsedTime >= frameTimeout) { - // Save the current removeScheduler before resetting state - // to prevent race condition when fn() triggers new debounced calls - const currentRemoveScheduler = removeScheduler; - isScheduled = false; - frameCounter = 0; - startTime = 0; - removeScheduler = null; - const args = latestArgs; - latestArgs = undefined; - fn(...((args ?? []) as Parameters)); - if (currentRemoveScheduler) { - currentRemoveScheduler(); - } - } - }, - }; - - const cancel = () => { - if (isScheduled && removeScheduler) { - // Mark as cancelled so performUpdate skips execution if it runs this frame - // before GlobalScheduler processes the deferred removal from toRemove - cancelled = true; - removeScheduler(); - } - isScheduled = false; - frameCounter = 0; - startTime = 0; - removeScheduler = null; - latestArgs = undefined; - }; - - const flush = () => { - if (isScheduled) { - const currentRemoveScheduler = removeScheduler; - isScheduled = false; - frameCounter = 0; - startTime = 0; - removeScheduler = null; - const args = latestArgs; - latestArgs = undefined; - // Remove the old scheduler handle BEFORE calling fn() so that any - // re-scheduling triggered inside fn() is not accidentally canceled. - if (currentRemoveScheduler) { - currentRemoveScheduler(); - } - fn(...((args ?? []) as Parameters)); - } - }; - - const debouncedFn = ((...args: Parameters) => { - latestArgs = args; // Store latest arguments - frameCounter = 0; // Reset counter on each call - startTime = getNow(); // Reset start time on each call - cancelled = false; // A new call overrides any pending cancel - - if (!isScheduled) { - isScheduled = true; - removeScheduler = scheduler.addScheduler(debouncedScheduler, priority); - } - }) as T & { cancel: () => void; flush: () => void; isScheduled: () => boolean }; - - debouncedFn.cancel = cancel; - debouncedFn.flush = flush; - debouncedFn.isScheduled = () => { - return isScheduled; - }; - - return debouncedFn; -}; + options?: TDebounceOptions +) => T & { cancel: () => void; flush: () => void; isScheduled: () => boolean }; -/** - * Creates a throttled function that only executes at most once per frameInterval frames - * and frameTimeout milliseconds. Both conditions must be met for execution. - * @param fn - The function to throttle - * @param options - Configuration options - * @param options.priority - Scheduler priority (default: MEDIUM) - * @param options.frameInterval - Number of frames between executions (default: 1) - * @param options.frameTimeout - Minimum time in milliseconds between executions (default: 0) - * @returns A throttled version of the function with cancel and flush methods - */ -export const throttle = ) => void>( +export const throttle = privateThrottle as unknown as ) => void>( fn: T, - { priority = ESchedulerPriority.MEDIUM, frameInterval = 1, frameTimeout = 0 }: TDebounceOptions = {} -): T & { cancel: () => void; flush: () => void } => { - let frameCounter = 0; - let canExecute = true; - let isScheduled = false; - let removeScheduler: (() => void) | null = null; - let startTime = 0; - - const throttledScheduler = { - performUpdate: () => { - frameCounter++; - const currentTime = getNow(); - const elapsedTime = currentTime - startTime; - - if (frameCounter >= frameInterval && elapsedTime >= frameTimeout) { - // Save the current removeScheduler before resetting state - // to prevent race condition when new throttled calls happen - const currentRemoveScheduler = removeScheduler; - canExecute = true; - isScheduled = false; - frameCounter = 0; - startTime = 0; - removeScheduler = null; - if (currentRemoveScheduler) { - currentRemoveScheduler(); - } - } - }, - }; - - const cancel = () => { - if (isScheduled && removeScheduler) { - removeScheduler(); - removeScheduler = null; - } - isScheduled = false; - frameCounter = 0; - startTime = 0; - canExecute = true; // Reset throttle state - }; - - const flush = () => { - cancel(); // Reset the timer and allow immediate execution - }; - - const throttledFn = ((...args: Parameters) => { - if (canExecute) { - fn(...args); - canExecute = false; - frameCounter = 0; - startTime = getNow(); // Start timing from this execution - - if (!isScheduled) { - isScheduled = true; - removeScheduler = scheduler.addScheduler(throttledScheduler, priority); - } - } - }) as T & { cancel: () => void; flush: () => void }; - - throttledFn.cancel = cancel; - throttledFn.flush = flush; - - return throttledFn; -}; - -/** - * Usage examples: - * - * // Minimal usage - all defaults (1 frame, 0ms) - * const minimalDebounce = debounce(() => console.log('Minimal')); - * - * // Only time-based control - * const timeOnlyDebounce = debounce( - * (query: string) => console.log('Time-based search:', query), - * { frameTimeout: 200 } - * ); - * - * // Only frame-based control - * const frameOnlyDebounce = debounce( - * (query: string) => console.log('Frame-based search:', query), - * { frameInterval: 5 } - * ); - * - * // Both controls (original behavior) - * const dualControlDebounce = debounce( - * (query: string) => console.log('Dual control search:', query), - * { frameInterval: 5, frameTimeout: 100 } - * ); - * - * // Throttle examples - * const immediateThrottle = throttle( - * () => console.log('Immediate execution'), - * { frameTimeout: 50 } // Only time limit - * ); - * - * const performanceThrottle = throttle( - * (event: MouseEvent) => console.log('Mouse at:', event.clientX, event.clientY), - * { frameInterval: 2, frameTimeout: 16 } // ~30fps with frame control - * ); - * - * // High priority throttle with minimal setup - * const priorityThrottle = throttle( - * () => console.log('High priority task'), - * { priority: ESchedulerPriority.HIGH } - * ); - * - * // Usage scenarios: - * minimalDebounce(); // Executes after 1 frame - * timeOnlyDebounce('test'); // Executes after 200ms (ignoring frames) - * frameOnlyDebounce('test'); // Executes after 5 frames (ignoring time) - * dualControlDebounce('test'); // Executes after 5 frames AND 100ms - * - * // Using cancel and flush methods: - * - * // Debounce with cancel - * const searchDebounce = debounce( - * (query: string) => console.log('Searching:', query), - * { frameInterval: 10, frameTimeout: 300 } - * ); - * searchDebounce('test'); - * searchDebounce.cancel(); // Cancels the pending execution - * - * // Debounce with flush - * const saveDebounce = debounce( - * (data: string) => console.log('Saving:', data), - * { frameTimeout: 1000 } - * ); - * saveDebounce('document'); - * saveDebounce.flush(); // Immediately executes with 'document' - * - * // Throttle with cancel - * const scrollThrottle = throttle( - * () => console.log('Scroll handled'), - * { frameInterval: 3, frameTimeout: 50 } - * ); - * scrollThrottle(); - * scrollThrottle.cancel(); // Resets throttle state, allows immediate next execution - * - * // Throttle with flush - * const resizeThrottle = throttle( - * () => console.log('Resize handled'), - * { frameTimeout: 100 } - * ); - * resizeThrottle(); - * resizeThrottle.flush(); // Resets throttle timer, allows immediate next execution - */ + options?: TDebounceOptions +) => T & { cancel: () => void; flush: () => void }; diff --git a/packages/graph/tests/package-contract/README.md b/packages/graph/tests/package-contract/README.md index 238572d5..7c844511 100644 --- a/packages/graph/tests/package-contract/README.md +++ b/packages/graph/tests/package-contract/README.md @@ -116,6 +116,9 @@ in `apps/e2e` instead. - required root, ESM, CommonJS, declaration, stylesheet, and documentation files are present; - the installed manifest has the expected package entrypoints, `files`, `exports`, `typesVersions`, and optional peer metadata; +- the private scheduler remains a build-only workspace dependency, its implementation is inlined into generated + JavaScript, its package specifier is absent from JavaScript and declarations, and it is neither exposed as a runtime + dependency nor installed in isolated consumers; - published CSS contains the vanilla canvas, React canvas, block, anchor, and devtools selectors; - `publint --strict` accepts the exact tarball installed by the consumers. @@ -132,7 +135,8 @@ and `noEmit: true`: - the vanilla and React applications type-check with Bundler resolution; - `types/playwright-bundler` verifies that `GraphPO.evaluate` receives the public `Graph`, block and connection state methods return public types rather than `any`, and `GraphPoint` and `clickAt` remain usable; -- `types/node-esm` imports `Graph`, `GraphCanvas`, and `useElk` through Node16 ESM resolution; +- `types/node-esm` imports `Graph`, the public scheduler contracts, `GraphCanvas`, and `useElk` through Node16 ESM + resolution; - `types/node-cjs-playwright` imports `GraphPO` and `GraphCameraState` through Node16 CommonJS resolution. “Node16” names TypeScript's module-resolution semantics here; it does not mean the suite executes on Node.js 16. @@ -145,7 +149,8 @@ callers pointed at the same fixture. `checks/runtime.mjs` runs Node against the installed package: -- ESM imports expose `Graph`, `GraphCanvas`, and `GraphPO` from their documented entrypoints; +- ESM imports expose `Graph`, `ESchedulerPriority`, `schedule`, `debounce`, `throttle`, `GraphCanvas`, and `GraphPO` from + their documented entrypoints; - CommonJS `require("@gravity-ui/graph/playwright")` exposes `GraphPO`; - the vanilla consumer cannot resolve React, proving the core and Playwright entrypoints do not require it eagerly; - a consumer-side `@preact/signals-core` effect observes a Graph signal update exactly once after its initial run. diff --git a/packages/graph/tests/package-contract/checks/artifact.mjs b/packages/graph/tests/package-contract/checks/artifact.mjs index c2abb7c6..edfe8f4f 100644 --- a/packages/graph/tests/package-contract/checks/artifact.mjs +++ b/packages/graph/tests/package-contract/checks/artifact.mjs @@ -1,9 +1,11 @@ import assert from "node:assert/strict"; -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { run } from "../utils.mjs"; +const privateSchedulerPackage = "@gravity-ui/graph-scheduler"; + const expectedExports = { ".": { types: "./build/index.d.ts", @@ -111,12 +113,43 @@ async function assertPathDoesNotExist(targetPath, message) { assert.fail(message); } +async function collectGeneratedContractFiles(directory) { + const generatedFiles = []; + + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + generatedFiles.push(...(await collectGeneratedContractFiles(entryPath))); + } else if ([".js", ".cjs", ".d.ts", ".d.cts"].some((suffix) => entry.name.endsWith(suffix))) { + generatedFiles.push(entryPath); + } + } + + return generatedFiles; +} + +async function assertNoPrivateSchedulerSpecifiers(packageRoot) { + const buildDirectory = path.join(packageRoot, "build"); + + for (const generatedFile of await collectGeneratedContractFiles(buildDirectory)) { + const contents = await readFile(generatedFile, "utf8"); + + assert.equal( + contents.includes(privateSchedulerPackage), + false, + `Generated artifact ${path.relative(packageRoot, generatedFile)} references ${privateSchedulerPackage}.` + ); + } +} + export async function buildAndPackArtifact({ packageRoot, staleBuildSentinelPath, tarballPath }) { console.log("\n[package-contract] Building published files..."); await mkdir(path.dirname(staleBuildSentinelPath), { recursive: true }); await writeFile(staleBuildSentinelPath, "The production build must remove this stale artifact.\n"); await run("pnpm", ["run", "build"], { cwd: packageRoot }); await assertPathDoesNotExist(staleBuildSentinelPath, "The production build did not clean its output directory."); + await assertNoPrivateSchedulerSpecifiers(packageRoot); console.log("\n[package-contract] Packing @gravity-ui/graph and checking the tarball allowlist..."); const packMetadata = getPackMetadata( @@ -151,6 +184,24 @@ export async function checkInstalledArtifact(consumerDirectory) { assert.equal(manifest.peerDependenciesMeta?.react?.optional, true); assert.equal(manifest.peerDependenciesMeta?.["react-dom"]?.optional, true); + for (const dependencyField of ["dependencies", "optionalDependencies", "peerDependencies"]) { + assert.equal( + manifest[dependencyField]?.[privateSchedulerPackage], + undefined, + `Packed manifest exposes ${privateSchedulerPackage} through ${dependencyField}.` + ); + } + + for (const bundledDependencyField of ["bundledDependencies", "bundleDependencies"]) { + const bundledDependencies = manifest[bundledDependencyField]; + + assert.equal( + Array.isArray(bundledDependencies) && bundledDependencies.includes(privateSchedulerPackage), + false, + `Packed manifest bundles the private package through ${bundledDependencyField}.` + ); + } + await Promise.all( [ "build/index.js", @@ -168,6 +219,12 @@ export async function checkInstalledArtifact(consumerDirectory) { ].map((relativePath) => access(path.join(packageRoot, relativePath))) ); + await assertPathDoesNotExist( + path.join(consumerDirectory, "node_modules", ...privateSchedulerPackage.split("/")), + `The isolated consumer installed the private package ${privateSchedulerPackage}.` + ); + await assertNoPrivateSchedulerSpecifiers(packageRoot); + const publicStyles = await readFile(path.join(packageRoot, "build", "styles.css"), "utf8"); assert.match(publicStyles, /\.layer\b/, "Public styles do not include the vanilla canvas contract."); assert.match(publicStyles, /\.graph-wrapper\b/, "Public styles do not include the React canvas contract."); diff --git a/packages/graph/tests/package-contract/checks/runtime.mjs b/packages/graph/tests/package-contract/checks/runtime.mjs index 6e6cdd17..cfe2f005 100644 --- a/packages/graph/tests/package-contract/checks/runtime.mjs +++ b/packages/graph/tests/package-contract/checks/runtime.mjs @@ -4,6 +4,14 @@ const nativeImportProbes = { root: ` const graph = await import("@gravity-ui/graph"); if (typeof graph.Graph !== "function") throw new Error("Root entrypoint does not export Graph."); + if (graph.ESchedulerPriority?.LOWEST !== 4) { + throw new Error("Root entrypoint does not export ESchedulerPriority."); + } + for (const schedulerFunction of ["schedule", "debounce", "throttle"]) { + if (typeof graph[schedulerFunction] !== "function") { + throw new Error(\`Root entrypoint does not export \${schedulerFunction}.\`); + } + } `, react: ` const react = await import("@gravity-ui/graph/react"); diff --git a/packages/graph/tests/package-contract/fixtures/types/node-esm/index.mts b/packages/graph/tests/package-contract/fixtures/types/node-esm/index.mts index 16835a8b..d6781d50 100644 --- a/packages/graph/tests/package-contract/fixtures/types/node-esm/index.mts +++ b/packages/graph/tests/package-contract/fixtures/types/node-esm/index.mts @@ -1,6 +1,19 @@ -import { Graph } from "@gravity-ui/graph"; +import { ESchedulerPriority, Graph, debounce, schedule, throttle } from "@gravity-ui/graph"; import { GraphCanvas, useElk } from "@gravity-ui/graph/react"; +const removeScheduledTask = schedule(() => undefined, { + priority: ESchedulerPriority.LOWEST, + frameInterval: 1, + once: true, +}); +const debounced = debounce((value: string) => void value, { priority: ESchedulerPriority.LOW }); +const throttled = throttle((value: string) => void value, { priority: ESchedulerPriority.HIGH }); + +debounced("debounced"); +debounced.isScheduled(); +throttled("throttled"); + void Graph; void GraphCanvas; void useElk; +void removeScheduledTask; diff --git a/packages/scheduler/.eslintrc b/packages/scheduler/.eslintrc new file mode 100644 index 00000000..15f73cb6 --- /dev/null +++ b/packages/scheduler/.eslintrc @@ -0,0 +1,72 @@ +{ + "extends": [ + "@gravity-ui/eslint-config", + "@gravity-ui/eslint-config/import-order", + "@gravity-ui/eslint-config/prettier" + ], + "parserOptions": { + "project": [ + "./tsconfig.json" + ] + }, + "root": true, + "env": { + "browser": true, + "node": true, + "jest": true + }, + "overrides": [ + { + "files": [ + "*.ts", + "*.mts", + "*.cts", + "*.tsx" + ], + "rules": { + "@typescript-eslint/explicit-member-accessibility": [ + "error", + { + "accessibility": "explicit", + "overrides": { + "accessors": "explicit", + "constructors": "no-public", + "methods": "explicit", + "properties": "off", + "parameterProperties": "explicit" + } + } + ], + "no-bitwise": [ + "error", + { + "int32Hint": true + } + ] + } + }, + { + "files": [ + "**/*.test.ts", + "**/*.test.tsx" + ], + "rules": { + "@typescript-eslint/no-explicit-any": "off" + } + } + ], + "rules": { + "no-negated-condition": "off", + "@typescript-eslint/parameter-properties": "off", + "no-param-reassign": "off", + "guard-for-in": "off", + "no-return-assign": "off", + "@typescript-eslint/member-ordering": "off", + "@typescript-eslint/no-shadow": "off", + "valid-jsdoc": "off", + "import/consistent-type-specifier-style": [ + "error", + "prefer-top-level" + ] + } +} diff --git a/packages/scheduler/jest.config.cjs b/packages/scheduler/jest.config.cjs new file mode 100644 index 00000000..89cf8533 --- /dev/null +++ b/packages/scheduler/jest.config.cjs @@ -0,0 +1,8 @@ +module.exports = { + testEnvironment: "node", + testPathIgnorePatterns: ["/node_modules/", "/build/"], + transformIgnorePatterns: [], + transform: { + "^.+\\.(t|j)sx?$": "@swc/jest", + }, +}; diff --git a/packages/scheduler/package.json b/packages/scheduler/package.json new file mode 100644 index 00000000..dcbe1f80 --- /dev/null +++ b/packages/scheduler/package.json @@ -0,0 +1,33 @@ +{ + "name": "@gravity-ui/graph-scheduler", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "module": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\" --config .eslintrc", + "test:unit": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests --updateSnapshot", + "test": "pnpm run test:unit" + }, + "devDependencies": { + "@gravity-ui/eslint-config": "^3.2.0", + "@swc/core": "^1.13.3", + "@swc/jest": "^0.2.39", + "@types/jest": "^30.0.0", + "cross-env": "^7.0.3", + "eslint": "^8.0.0", + "jest": "^30.0.5", + "prettier": "^3.0.0", + "typescript": "^5.9.2" + } +} diff --git a/packages/scheduler/src/Scheduler.test.ts b/packages/scheduler/src/Scheduler.test.ts new file mode 100644 index 00000000..0ef1741c --- /dev/null +++ b/packages/scheduler/src/Scheduler.test.ts @@ -0,0 +1,73 @@ +import { GlobalScheduler } from "./Scheduler"; + +describe("GlobalScheduler", () => { + let scheduler: GlobalScheduler; + + beforeEach(() => { + scheduler = new GlobalScheduler(); + }); + + afterEach(() => { + scheduler.destroy(); + }); + + it("adds schedulers to the requested and default priorities", () => { + const explicitPriority = { performUpdate: jest.fn() }; + const defaultPriority = { performUpdate: jest.fn() }; + + scheduler.addScheduler(explicitPriority, 1); + scheduler.addScheduler(defaultPriority); + + expect(scheduler.getSchedulers()[1]).toEqual([explicitPriority]); + expect(scheduler.getSchedulers()[2]).toEqual([defaultPriority]); + expect(scheduler.getSchedulers().flat()).toHaveLength(2); + + scheduler.performUpdate(); + + expect(explicitPriority.performUpdate).toHaveBeenCalledTimes(1); + expect(explicitPriority.performUpdate).toHaveBeenCalledWith(expect.any(Number)); + expect(defaultPriority.performUpdate).toHaveBeenCalledTimes(1); + }); + + it("defers removal until the next update completes", () => { + const task = { performUpdate: jest.fn() }; + const remove = scheduler.addScheduler(task, 3); + + remove(); + + expect(scheduler.getSchedulers()[3]).toEqual([task]); + + scheduler.performUpdate(); + + expect(task.performUpdate).toHaveBeenCalledTimes(1); + expect(scheduler.getSchedulers()[3]).toEqual([]); + + scheduler.performUpdate(); + + expect(task.performUpdate).toHaveBeenCalledTimes(1); + }); + + it("does not skip remaining schedulers when one removes itself", () => { + let removeFirst = () => {}; + const first = { + performUpdate: jest.fn(() => removeFirst()), + }; + const second = { + performUpdate: jest.fn(), + }; + + removeFirst = scheduler.addScheduler(first); + scheduler.addScheduler(second); + + scheduler.performUpdate(); + + expect(first.performUpdate).toHaveBeenCalledTimes(1); + expect(second.performUpdate).toHaveBeenCalledTimes(1); + expect(scheduler.getSchedulers()[2]).toEqual([second]); + + scheduler.performUpdate(); + + expect(first.performUpdate).toHaveBeenCalledTimes(1); + expect(second.performUpdate).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/scheduler/src/Scheduler.ts b/packages/scheduler/src/Scheduler.ts new file mode 100644 index 00000000..484c6801 --- /dev/null +++ b/packages/scheduler/src/Scheduler.ts @@ -0,0 +1,183 @@ +const rAF: Function = + typeof window !== "undefined" ? window.requestAnimationFrame : (fn) => globalThis.setTimeout(fn, 16); +const cAF: Function = typeof window !== "undefined" ? window.cancelAnimationFrame : globalThis.clearTimeout; +const getNow = + typeof window !== "undefined" + ? window.performance.now.bind(window.performance) + : globalThis.Date.now.bind(globalThis.Date); + +interface IScheduler { + performUpdate: (time: number) => void; +} + +export type TSchedulerPriority = 0 | 1 | 2 | 3 | 4; + +type TSchedulerTreeNode = { + data: { + iterate: () => boolean; + }; +}; + +type TSchedulerTree = { + traverseDown: (iterator: (node: TSchedulerTreeNode) => boolean) => void; +}; + +const MEDIUM_PRIORITY: TSchedulerPriority = 2; + +export class GlobalScheduler { + private schedulers: [IScheduler[], IScheduler[], IScheduler[], IScheduler[], IScheduler[]]; + private _cAFID: number; + private toRemove: Array<[IScheduler, TSchedulerPriority]> = []; + private visibilityChangeHandler: (() => void) | null = null; + + constructor() { + this.tick = this.tick.bind(this); + this.handleVisibilityChange = this.handleVisibilityChange.bind(this); + + this.schedulers = [[], [], [], [], []]; + this.setupVisibilityListener(); + } + + /** + * Setup listener for page visibility changes. + * When tab becomes visible after being hidden, force immediate update. + * This fixes the issue where tabs opened in background don't render HTML until interaction. + */ + private setupVisibilityListener(): void { + if (typeof document === "undefined") { + return; // Not in browser environment + } + + this.visibilityChangeHandler = this.handleVisibilityChange; + document.addEventListener("visibilitychange", this.visibilityChangeHandler); + } + + /** + * Handle page visibility changes. + * When page becomes visible, perform immediate update if scheduler is running. + */ + private handleVisibilityChange(): void { + // Only update if page becomes visible and scheduler is running + if (!document.hidden && this._cAFID) { + // Perform immediate update when tab becomes visible + this.performUpdate(); + } + } + + /** + * Cleanup visibility listener + */ + private cleanupVisibilityListener(): void { + if (this.visibilityChangeHandler && typeof document !== "undefined") { + document.removeEventListener("visibilitychange", this.visibilityChangeHandler); + this.visibilityChangeHandler = null; + } + } + + public getSchedulers() { + return this.schedulers; + } + + public addScheduler(scheduler: IScheduler, index: TSchedulerPriority = MEDIUM_PRIORITY) { + this.schedulers[index].push(scheduler); + return () => this.removeScheduler(scheduler, index); + } + + public removeScheduler(scheduler: IScheduler, index: TSchedulerPriority = MEDIUM_PRIORITY) { + this.toRemove.push([scheduler, index]); + } + + public start() { + if (!this._cAFID) { + this._cAFID = rAF(this.tick); + } + } + + public stop() { + cAF(this._cAFID); + this._cAFID = undefined; + } + + /** + * Cleanup method to be called when GlobalScheduler is no longer needed. + * Stops the scheduler and removes event listeners. + */ + public destroy(): void { + this.stop(); + this.cleanupVisibilityListener(); + } + + public tick() { + this.performUpdate(); + this._cAFID = rAF(this.tick); + } + + public performUpdate() { + const startTime = getNow(); + let schedulers: IScheduler[] = []; + + for (let i = 0; i < this.schedulers.length; i += 1) { + schedulers = this.schedulers[i]; + + for (let j = 0; j < schedulers.length; j += 1) { + schedulers[j].performUpdate(getNow() - startTime); + } + } + + // Process deferred removals after all schedulers have been executed + for (const [scheduler, index] of this.toRemove) { + const schedulerIndex = this.schedulers[index].indexOf(scheduler); + if (schedulerIndex !== -1) { + this.schedulers[index].splice(schedulerIndex, 1); + } + } + this.toRemove.length = 0; + } +} + +export const globalScheduler = new GlobalScheduler(); + +export const scheduler = globalScheduler; +export class Scheduler { + private sheduled: boolean; + private root: TSchedulerTree; + + constructor() { + this.performUpdate = this.performUpdate.bind(this); + + this.sheduled = false; + + globalScheduler.addScheduler(this); + } + + public setRoot(root: TSchedulerTree) { + this.root = root; + } + + public start() { + globalScheduler.addScheduler(this); + } + + public stop() { + globalScheduler.removeScheduler(this); + } + + public update() { + this.root?.traverseDown(this.iterator); + } + + public iterator(node: TSchedulerTreeNode) { + return node.data.iterate(); + } + + public scheduleUpdate() { + this.sheduled = true; + } + + public performUpdate() { + if (this.sheduled) { + this.sheduled = false; + this.update(); + } + } +} diff --git a/packages/scheduler/src/index.ts b/packages/scheduler/src/index.ts new file mode 100644 index 00000000..61f77aec --- /dev/null +++ b/packages/scheduler/src/index.ts @@ -0,0 +1,4 @@ +export { GlobalScheduler, Scheduler, globalScheduler, scheduler } from "./Scheduler"; +export type { TSchedulerPriority } from "./Scheduler"; +export { debounce, schedule, throttle } from "./schedule"; +export type { TDebounceOptions, TScheduleOptions } from "./schedule"; diff --git a/packages/scheduler/src/schedule.test.ts b/packages/scheduler/src/schedule.test.ts new file mode 100644 index 00000000..bafccdea --- /dev/null +++ b/packages/scheduler/src/schedule.test.ts @@ -0,0 +1,147 @@ +import { scheduler } from "./Scheduler"; +import { debounce, schedule, throttle } from "./schedule"; + +const MEDIUM_PRIORITY = 2; +const LOWEST_PRIORITY = 4; + +function resetScheduler() { + for (const scheduledTasks of scheduler.getSchedulers()) { + scheduledTasks.length = 0; + } + + // Drain deferred removals left by cancel/remove handles. + scheduler.performUpdate(); +} + +describe("schedule", () => { + beforeEach(resetScheduler); + afterEach(resetScheduler); + + it("runs at the configured frame interval until removed", () => { + const callback = jest.fn(); + const remove = schedule(callback, { + priority: MEDIUM_PRIORITY, + frameInterval: 2, + }); + + scheduler.performUpdate(); + expect(callback).not.toHaveBeenCalled(); + + scheduler.performUpdate(); + expect(callback).toHaveBeenCalledTimes(1); + + remove(); + scheduler.performUpdate(); + scheduler.performUpdate(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("runs only once when requested", () => { + const callback = jest.fn(); + + schedule(callback, { + priority: MEDIUM_PRIORITY, + frameInterval: 1, + once: true, + }); + + scheduler.performUpdate(); + scheduler.performUpdate(); + + expect(callback).toHaveBeenCalledTimes(1); + }); +}); + +describe("debounce", () => { + beforeEach(resetScheduler); + afterEach(resetScheduler); + + it("runs once with the latest arguments after the configured frame interval", () => { + const callback = jest.fn(); + const debounced = debounce(callback, { + priority: MEDIUM_PRIORITY, + frameInterval: 2, + }); + + debounced("first"); + debounced("latest"); + + scheduler.performUpdate(); + expect(callback).not.toHaveBeenCalled(); + + scheduler.performUpdate(); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith("latest"); + expect(debounced.isScheduled()).toBe(false); + }); + + it("cancels a pending call", () => { + const callback = jest.fn(); + const debounced = debounce(callback, { priority: MEDIUM_PRIORITY }); + + debounced("pending"); + debounced.cancel(); + scheduler.performUpdate(); + + expect(callback).not.toHaveBeenCalled(); + expect(debounced.isScheduled()).toBe(false); + }); + + it("keeps pending schedule when flush callback re-schedules", () => { + type TDebounced = ReturnType void>>; + const debouncedRef: { fn: TDebounced | null } = { fn: null }; + + const callback = jest.fn((arg: string) => { + if (arg === "initial") { + debouncedRef.fn?.("follow-up"); + } + }); + + debouncedRef.fn = debounce(callback, { + frameInterval: 1, + frameTimeout: 0, + priority: LOWEST_PRIORITY, + }); + const debounced = debouncedRef.fn; + + debounced("initial"); + debounced.flush(); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith("initial"); + expect(debounced.isScheduled()).toBe(true); + }); +}); + +describe("throttle", () => { + beforeEach(resetScheduler); + afterEach(resetScheduler); + + it("runs immediately and suppresses calls until the configured frame interval elapses", () => { + const callback = jest.fn(); + const throttled = throttle(callback, { + priority: MEDIUM_PRIORITY, + frameInterval: 2, + }); + + throttled("first"); + throttled("suppressed"); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenLastCalledWith("first"); + + scheduler.performUpdate(); + throttled("still-suppressed"); + expect(callback).toHaveBeenCalledTimes(1); + + scheduler.performUpdate(); + throttled("next"); + + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith("next"); + + throttled.cancel(); + }); +}); diff --git a/packages/scheduler/src/schedule.ts b/packages/scheduler/src/schedule.ts new file mode 100644 index 00000000..0c9315cc --- /dev/null +++ b/packages/scheduler/src/schedule.ts @@ -0,0 +1,307 @@ +import type { TSchedulerPriority } from "./Scheduler"; +import { scheduler } from "./Scheduler"; + +// Helper to get current time (similar to scheduler implementation) +const getNow = + typeof globalThis !== "undefined" + ? globalThis.performance.now.bind(globalThis.performance) + : globalThis.Date.now.bind(globalThis.Date); + +export type TScheduleOptions = { + priority: TSchedulerPriority; + frameInterval: number; + once?: boolean; +}; + +export const schedule = (fn: Function, options: TScheduleOptions) => { + const { priority, frameInterval, once } = options; + let frameCounter = 0; + let isRemoved = false; + const debounceScheduler = { + performUpdate: () => { + frameCounter++; + if (frameCounter >= frameInterval) { + if (once && !isRemoved) { + scheduler.removeScheduler(debounceScheduler, priority); + isRemoved = true; + } + fn(); + frameCounter = 0; + if (once) { + isRemoved = true; + scheduler.removeScheduler(debounceScheduler, priority); + } + } + }, + }; + return scheduler.addScheduler(debounceScheduler, priority); +}; + +export type TDebounceOptions = { + priority?: TSchedulerPriority; + frameInterval?: number; + frameTimeout?: number; +}; + +/** + * Creates a debounced function that delays execution until after frameInterval frames + * and frameTimeout milliseconds have passed since it was last invoked. + * Both conditions must be met for execution. + * @param fn - The function to debounce + * @param options - Configuration options + * @param options.priority - Scheduler priority (default: MEDIUM) + * @param options.frameInterval - Number of frames to wait before execution (default: 1) + * @param options.frameTimeout - Minimum time in milliseconds to wait before execution (default: 0) + * @returns A debounced version of the function with cancel and flush methods + */ +export const debounce = ) => void>( + fn: T, + { priority = 2, frameInterval = 1, frameTimeout = 0 }: TDebounceOptions = {} +): T & { cancel: () => void; flush: () => void; isScheduled: () => boolean } => { + let frameCounter = 0; + let isScheduled = false; + let cancelled = false; + let removeScheduler: (() => void) | null = null; + let latestArgs: Parameters | undefined; + let startTime = 0; + + const debouncedScheduler = { + performUpdate: () => { + // cancel() was called before this frame's performUpdate ran — skip execution + if (cancelled) { + cancelled = false; + return; + } + frameCounter++; + const currentTime = getNow(); + const elapsedTime = currentTime - startTime; + + if (frameCounter >= frameInterval && elapsedTime >= frameTimeout) { + // Save the current removeScheduler before resetting state + // to prevent race condition when fn() triggers new debounced calls + const currentRemoveScheduler = removeScheduler; + isScheduled = false; + frameCounter = 0; + startTime = 0; + removeScheduler = null; + const args = latestArgs; + latestArgs = undefined; + fn(...((args ?? []) as Parameters)); + if (currentRemoveScheduler) { + currentRemoveScheduler(); + } + } + }, + }; + + const cancel = () => { + if (isScheduled && removeScheduler) { + // Mark as cancelled so performUpdate skips execution if it runs this frame + // before GlobalScheduler processes the deferred removal from toRemove + cancelled = true; + removeScheduler(); + } + isScheduled = false; + frameCounter = 0; + startTime = 0; + removeScheduler = null; + latestArgs = undefined; + }; + + const flush = () => { + if (isScheduled) { + const currentRemoveScheduler = removeScheduler; + isScheduled = false; + frameCounter = 0; + startTime = 0; + removeScheduler = null; + const args = latestArgs; + latestArgs = undefined; + // Remove the old scheduler handle BEFORE calling fn() so that any + // re-scheduling triggered inside fn() is not accidentally canceled. + if (currentRemoveScheduler) { + currentRemoveScheduler(); + } + fn(...((args ?? []) as Parameters)); + } + }; + + const debouncedFn = ((...args: Parameters) => { + latestArgs = args; // Store latest arguments + frameCounter = 0; // Reset counter on each call + startTime = getNow(); // Reset start time on each call + cancelled = false; // A new call overrides any pending cancel + + if (!isScheduled) { + isScheduled = true; + removeScheduler = scheduler.addScheduler(debouncedScheduler, priority); + } + }) as T & { cancel: () => void; flush: () => void; isScheduled: () => boolean }; + + debouncedFn.cancel = cancel; + debouncedFn.flush = flush; + debouncedFn.isScheduled = () => { + return isScheduled; + }; + + return debouncedFn; +}; + +/** + * Creates a throttled function that only executes at most once per frameInterval frames + * and frameTimeout milliseconds. Both conditions must be met for execution. + * @param fn - The function to throttle + * @param options - Configuration options + * @param options.priority - Scheduler priority (default: MEDIUM) + * @param options.frameInterval - Number of frames between executions (default: 1) + * @param options.frameTimeout - Minimum time in milliseconds between executions (default: 0) + * @returns A throttled version of the function with cancel and flush methods + */ +export const throttle = ) => void>( + fn: T, + { priority = 2, frameInterval = 1, frameTimeout = 0 }: TDebounceOptions = {} +): T & { cancel: () => void; flush: () => void } => { + let frameCounter = 0; + let canExecute = true; + let isScheduled = false; + let removeScheduler: (() => void) | null = null; + let startTime = 0; + + const throttledScheduler = { + performUpdate: () => { + frameCounter++; + const currentTime = getNow(); + const elapsedTime = currentTime - startTime; + + if (frameCounter >= frameInterval && elapsedTime >= frameTimeout) { + // Save the current removeScheduler before resetting state + // to prevent race condition when new throttled calls happen + const currentRemoveScheduler = removeScheduler; + canExecute = true; + isScheduled = false; + frameCounter = 0; + startTime = 0; + removeScheduler = null; + if (currentRemoveScheduler) { + currentRemoveScheduler(); + } + } + }, + }; + + const cancel = () => { + if (isScheduled && removeScheduler) { + removeScheduler(); + removeScheduler = null; + } + isScheduled = false; + frameCounter = 0; + startTime = 0; + canExecute = true; // Reset throttle state + }; + + const flush = () => { + cancel(); // Reset the timer and allow immediate execution + }; + + const throttledFn = ((...args: Parameters) => { + if (canExecute) { + fn(...args); + canExecute = false; + frameCounter = 0; + startTime = getNow(); // Start timing from this execution + + if (!isScheduled) { + isScheduled = true; + removeScheduler = scheduler.addScheduler(throttledScheduler, priority); + } + } + }) as T & { cancel: () => void; flush: () => void }; + + throttledFn.cancel = cancel; + throttledFn.flush = flush; + + return throttledFn; +}; + +/** + * Usage examples: + * + * // Minimal usage - all defaults (1 frame, 0ms) + * const minimalDebounce = debounce(() => console.log('Minimal')); + * + * // Only time-based control + * const timeOnlyDebounce = debounce( + * (query: string) => console.log('Time-based search:', query), + * { frameTimeout: 200 } + * ); + * + * // Only frame-based control + * const frameOnlyDebounce = debounce( + * (query: string) => console.log('Frame-based search:', query), + * { frameInterval: 5 } + * ); + * + * // Both controls (original behavior) + * const dualControlDebounce = debounce( + * (query: string) => console.log('Dual control search:', query), + * { frameInterval: 5, frameTimeout: 100 } + * ); + * + * // Throttle examples + * const immediateThrottle = throttle( + * () => console.log('Immediate execution'), + * { frameTimeout: 50 } // Only time limit + * ); + * + * const performanceThrottle = throttle( + * (event: MouseEvent) => console.log('Mouse at:', event.clientX, event.clientY), + * { frameInterval: 2, frameTimeout: 16 } // ~30fps with frame control + * ); + * + * // High priority throttle with minimal setup + * const priorityThrottle = throttle( + * () => console.log('High priority task'), + * { priority: ESchedulerPriority.HIGH } + * ); + * + * // Usage scenarios: + * minimalDebounce(); // Executes after 1 frame + * timeOnlyDebounce('test'); // Executes after 200ms (ignoring frames) + * frameOnlyDebounce('test'); // Executes after 5 frames (ignoring time) + * dualControlDebounce('test'); // Executes after 5 frames AND 100ms + * + * // Using cancel and flush methods: + * + * // Debounce with cancel + * const searchDebounce = debounce( + * (query: string) => console.log('Searching:', query), + * { frameInterval: 10, frameTimeout: 300 } + * ); + * searchDebounce('test'); + * searchDebounce.cancel(); // Cancels the pending execution + * + * // Debounce with flush + * const saveDebounce = debounce( + * (data: string) => console.log('Saving:', data), + * { frameTimeout: 1000 } + * ); + * saveDebounce('document'); + * saveDebounce.flush(); // Immediately executes with 'document' + * + * // Throttle with cancel + * const scrollThrottle = throttle( + * () => console.log('Scroll handled'), + * { frameInterval: 3, frameTimeout: 50 } + * ); + * scrollThrottle(); + * scrollThrottle.cancel(); // Resets throttle state, allows immediate next execution + * + * // Throttle with flush + * const resizeThrottle = throttle( + * () => console.log('Resize handled'), + * { frameTimeout: 100 } + * ); + * resizeThrottle(); + * resizeThrottle.flush(); // Resets throttle timer, allows immediate next execution + */ diff --git a/packages/scheduler/tsconfig.json b/packages/scheduler/tsconfig.json new file mode 100644 index 00000000..a32f0319 --- /dev/null +++ b/packages/scheduler/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "module": "esnext", + "target": "es2020", + "noEmit": true, + "skipLibCheck": true, + "types": ["jest"] + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40861970..03fc729a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,6 +188,9 @@ importers: '@gravity-ui/eslint-config': specifier: ^3.2.0 version: 3.2.0(@types/eslint@9.6.1)(eslint@8.57.0)(prettier@3.3.2)(typescript@5.9.2) + '@gravity-ui/graph-scheduler': + specifier: workspace:* + version: link:../scheduler '@gravity-ui/prettier-config': specifier: ^1.1.0 version: 1.1.0(prettier@3.3.2) @@ -294,6 +297,36 @@ importers: specifier: ^5.9.2 version: 5.9.2 + packages/scheduler: + devDependencies: + '@gravity-ui/eslint-config': + specifier: ^3.2.0 + version: 3.2.0(@types/eslint@9.6.1)(eslint@8.57.0)(prettier@3.3.2)(typescript@5.9.2) + '@swc/core': + specifier: ^1.13.3 + version: 1.13.3 + '@swc/jest': + specifier: ^0.2.39 + version: 0.2.39(@swc/core@1.13.3) + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + eslint: + specifier: ^8.0.0 + version: 8.57.0 + jest: + specifier: ^30.0.5 + version: 30.0.5(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.13.3)(@types/node@20.19.43)(typescript@5.9.2)) + prettier: + specifier: ^3.0.0 + version: 3.3.2 + typescript: + specifier: ^5.9.2 + version: 5.9.2 + packages: '@adobe/css-tools@4.4.3':