Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion packages/graph/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
48 changes: 46 additions & 2 deletions packages/graph/scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {})]),
];
Expand Down Expand Up @@ -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);
}
}
}
}
Expand All @@ -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);
Expand Down Expand Up @@ -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"]);
Expand Down
194 changes: 37 additions & 157 deletions packages/graph/src/lib/Scheduler.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
41 changes: 0 additions & 41 deletions packages/graph/src/utils/utils/schedule.test.ts

This file was deleted.

Loading