From 4eac36792fc94b3587a907f2ca6ab6fdd08c7246 Mon Sep 17 00:00:00 2001 From: Unnati1007 Date: Sun, 9 Aug 2026 23:19:22 +0530 Subject: [PATCH 1/2] fix(store): resolve async batch state leak and concurrent update rollback (fixes #8866) --- packages/store/src/store.test.ts | 36 +++++++ packages/store/src/store.ts | 159 ++++++++++++++++++++----------- 2 files changed, 137 insertions(+), 58 deletions(-) diff --git a/packages/store/src/store.test.ts b/packages/store/src/store.test.ts index 9ebe844d0..0c83edc8e 100644 --- a/packages/store/src/store.test.ts +++ b/packages/store/src/store.test.ts @@ -548,6 +548,42 @@ describe('batch', () => { expect(useStore.getState()).toEqual({ a: 1, b: 2 }) }) + it('async batch does not leak batch context to concurrent non-batched updates (issue 8866)', async () => { + const useStore = createStore(() => ({ x: 0, y: 0 })) + const spy = vi.fn() + useStore.subscribe(spy) + + let resolveBatch: () => void = () => {} + const p = batch(async () => { + useStore.setState({ x: 1 }) + await new Promise((resolve) => { + resolveBatch = resolve + }) + useStore.setState({ x: 2 }) + }) + + // While the batch is suspended, perform a non-batched update + useStore.setState({ y: 10 }) + + // The non-batched update should apply immediately and trigger listeners, + // because the async batch should not leak its batching context to concurrent tasks + expect(useStore.getState().y).toBe(10) + expect(spy).toHaveBeenCalledOnce() + expect(spy.mock.calls[0][0]).toEqual({ x: 0, y: 10 }) // x is still 0 (deferred), y is 10 (applied) + + spy.mockClear() + + // Resolve the batch + resolveBatch() + await p + await new Promise(resolve => queueMicrotask(resolve)) + + // The batch should now complete and notify + expect(useStore.getState()).toEqual({ x: 2, y: 10 }) + expect(spy).toHaveBeenCalledOnce() + expect(spy.mock.calls[0][0]).toEqual({ x: 2, y: 10 }) + }) + describe('middleware', () => { it('middleware is called during setState', () => { const spy = vi.fn((prev, update, next) => next(update)) diff --git a/packages/store/src/store.ts b/packages/store/src/store.ts index 7b2250fb4..c7a3d7edd 100644 --- a/packages/store/src/store.ts +++ b/packages/store/src/store.ts @@ -24,6 +24,7 @@ import { useState, useEffect, useRef } from '@termuijs/jsx'; import * as path from 'node:path'; import * as fs from 'node:fs'; import * as os from 'node:os'; +import { AsyncLocalStorage } from 'node:async_hooks'; import type { EqualityFn } from './shallow.js' @@ -37,11 +38,14 @@ interface BatchEntry { rollback: () => void; } -let _batchDepth = 0; -let _batchEpoch = 0; -// Map store instance to batch entry. Using any for listener set type because -// the batch mechanism operates on the raw Set> without knowing T at this level. -const _batchStores = new Map, BatchEntry>(); +interface BatchContext { + depth: number; +} + +const batchLocalStorage = new AsyncLocalStorage(); +const globalBatchStores = new Map, BatchEntry>(); +let globalBatchEpoch = 0; + /** * Batch multiple state updates into a single render pass. * @@ -63,60 +67,96 @@ const _batchStores = new Map, BatchEntry>(); * ``` */ export function batch(fn: () => T): T { - const isOutermost = _batchDepth === 0; - _batchDepth++; - if (isOutermost) _batchEpoch++; - let threw = false; - let res: any; - try { - res = fn(); - } catch (err) { - threw = true; - _batchDepth--; - if (_batchDepth === 0) { - flushBatch(threw); + const parentContext = batchLocalStorage.getStore(); + + if (parentContext) { + parentContext.depth++; + try { + const res = fn(); + // Cast res through unknown to Promise to safely check if it is a thenable (Promise) without type errors on generic T + if (res && typeof (res as unknown as Promise).then === 'function') { + // Cast through unknown to Promise to chain the then callback before returning + return (res as unknown as Promise).then( + (val) => { + parentContext.depth--; + if (parentContext.depth === 0) flushBatch(false, true); + return val; + }, + (err) => { + parentContext.depth--; + if (parentContext.depth === 0) flushBatch(true, true); + throw err; + } + ) as T; + } else { + parentContext.depth--; + if (parentContext.depth === 0) { + flushBatch(false); + } + return res; + } + } catch (err) { + parentContext.depth--; + if (parentContext.depth === 0) { + flushBatch(true); + } + throw err; } - throw err; } - if (res && typeof res.then === 'function') { - return (res as Promise).then( - (val) => { - _batchDepth--; - if (_batchDepth === 0) flushBatch(false, true); - return val; - }, - (err) => { - _batchDepth--; - if (_batchDepth === 0) flushBatch(true, true); - throw err; + globalBatchEpoch++; + const context: BatchContext = { + depth: 1 + }; + + return batchLocalStorage.run(context, () => { + try { + const res = fn(); + // Cast res through unknown to Promise to safely check if it is a thenable (Promise) without type errors on generic T + if (res && typeof (res as unknown as Promise).then === 'function') { + // Cast through unknown to Promise to chain the then callback before returning + return (res as unknown as Promise).then( + (val) => { + context.depth--; + if (context.depth === 0) flushBatch(false, true); + return val; + }, + (err) => { + context.depth--; + if (context.depth === 0) flushBatch(true, true); + throw err; + } + ) as T; + } else { + context.depth--; + if (context.depth === 0) { + flushBatch(false); + } + return res; } - ) as T; - } else { - _batchDepth--; - if (_batchDepth === 0) { - flushBatch(false); + } catch (err) { + context.depth--; + if (context.depth === 0) { + flushBatch(true); + } + throw err; } - return res; - } + }); } function flushBatch(threw: boolean, immediate = false) { if (threw) { - for (const [, { rollback }] of _batchStores) { + for (const [, { rollback }] of globalBatchStores) { rollback(); } - _batchStores.clear(); + globalBatchStores.clear(); } else { - if (_batchStores.size === 0) return; - // Snapshot the current epoch so the microtask can bail out if a new - // batch has started before it runs. - const epochAtFlush = _batchEpoch; + if (globalBatchStores.size === 0) return; + const epochAtFlush = globalBatchEpoch; const notify = () => { - // A new batch started between flush and notify — skip. - if (_batchEpoch !== epochAtFlush) return; - const stores = Array.from(_batchStores.entries()); - _batchStores.clear(); + if (globalBatchEpoch !== epochAtFlush) return; + const stores = Array.from(globalBatchStores.entries()); + globalBatchStores.clear(); const newStates = new Map, any>(); for (const [listeners, { commit }] of stores) { newStates.set(listeners, commit()); @@ -387,16 +427,17 @@ export function createStore( const setState: SetState = (partial) => { const prevState = state; + const context = batchLocalStorage.getStore(); // When in a batch, function updaters should see the pending batch state - const batchState: T = _batchDepth > 0 ? _batchStores.get(listeners)?.nextState ?? state : state; + const batchState: T = context && context.depth > 0 ? globalBatchStores.get(listeners)?.nextState ?? state : state; const nextPartial = typeof partial === 'function' ? (partial as (state: T) => Partial)(batchState) : partial; const applyUpdate = (finalPartial: Partial): T => { // When in a batch, compute nextState from pending batch state if available - const baseState: T = _batchDepth > 0 ? _batchStores.get(listeners)?.nextState ?? state : state; + const baseState: T = context && context.depth > 0 ? globalBatchStores.get(listeners)?.nextState ?? state : state; const nextState = { ...baseState, ...finalPartial }; // Only notify if at least one key's value actually changed @@ -405,14 +446,14 @@ export function createStore( key => !Object.is((baseState as any)[key], (nextState as any)[key]) ); if (hasChanged) { - if (_batchDepth > 0) { + if (context && context.depth > 0) { // We're in a batch: defer listener notifications and track the final state - const existing = _batchStores.get(listeners); + const existing = globalBatchStores.get(listeners); if (!existing) { // Track only the keys changed inside the batch so commit can merge // onto any intermediate non-batched updates without overwriting them. const changes: Partial = { ...finalPartial }; - _batchStores.set(listeners, { + globalBatchStores.set(listeners, { prevState, nextState, changes, @@ -461,8 +502,9 @@ export function createStore( }; const getState: GetState = () => { - if (_batchDepth > 0) { - const entry = _batchStores.get(listeners); + const context = batchLocalStorage.getStore(); + if (context && context.depth > 0) { + const entry = globalBatchStores.get(listeners); if (entry) return entry.nextState; } return state; @@ -495,7 +537,7 @@ export function createStore( }; const destroy = (): void => { - _batchStores.delete(listeners); + globalBatchStores.delete(listeners); listeners.clear(); if (writeTimeout) { clearTimeout(writeTimeout); @@ -504,16 +546,17 @@ export function createStore( }; const mutate = (recipe: (draft: T) => void): void => { const prevState = state; + const context = batchLocalStorage.getStore(); // When in a batch, produce from pending batch state - const baseState: T = _batchDepth > 0 ? _batchStores.get(listeners)?.nextState ?? state : state; + const baseState: T = context && context.depth > 0 ? globalBatchStores.get(listeners)?.nextState ?? state : state; const nextState = produce(baseState, (draft) => { recipe(draft as T); }); if (Object.is(baseState, nextState)) { return; } - if (_batchDepth > 0) { - const existing = _batchStores.get(listeners); + if (context && context.depth > 0) { + const existing = globalBatchStores.get(listeners); // Compute which keys actually changed so commit can merge instead of replace const changedKeys = Object.keys(nextState).filter( k => !Object.is((nextState as any)[k], (baseState as any)[k]) @@ -523,7 +566,7 @@ export function createStore( (changes as any)[k] = (nextState as any)[k]; } if (!existing) { - _batchStores.set(listeners, { + globalBatchStores.set(listeners, { prevState, nextState, changes, From 0d422e58f826667d4946da99b4c960a364c0ad03 Mon Sep 17 00:00:00 2001 From: Unnati1007 Date: Sun, 9 Aug 2026 23:26:10 +0530 Subject: [PATCH 2/2] feat(ui): add Window and WindowManager components --- packages/ui/src/Window.test.ts | 105 ++++++++++++ packages/ui/src/Window.ts | 261 +++++++++++++++++++++++++++++ packages/ui/src/WindowManager.ts | 279 +++++++++++++++++++++++++++++++ packages/ui/src/index.ts | 5 + 4 files changed, 650 insertions(+) create mode 100644 packages/ui/src/Window.test.ts create mode 100644 packages/ui/src/Window.ts create mode 100644 packages/ui/src/WindowManager.ts diff --git a/packages/ui/src/Window.test.ts b/packages/ui/src/Window.test.ts new file mode 100644 index 000000000..754575d08 --- /dev/null +++ b/packages/ui/src/Window.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { Screen, caps } from '@termuijs/core'; +import { Text } from '@termuijs/widgets'; +import { Window } from './Window.js'; +import { WindowManager } from './WindowManager.js'; + +describe('Window and WindowManager', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Window constructor & properties', () => { + it('initializes with default options', () => { + const win = new Window({ title: 'Test Window' }); + expect(win.title).toBe('Test Window'); + expect(win.windowX).toBe(0); + expect(win.windowY).toBe(0); + expect(win.windowWidth).toBe(30); + expect(win.windowHeight).toBe(10); + expect(win.isMinimized).toBe(false); + expect(win.isMaximized).toBe(false); + expect(win.isClosed).toBe(false); + }); + + it('can minimize and maximize', () => { + const win = new Window({ x: 2, y: 3, width: 20, height: 10 }); + win.minimize(); + expect(win.isMinimized).toBe(true); + + win.maximize(); + expect(win.isMaximized).toBe(true); + win.maximize(); + expect(win.isMaximized).toBe(false); + expect(win.windowX).toBe(2); + expect(win.windowY).toBe(3); + }); + }); + + describe('Window click target resolution', () => { + it('identifies click targets in the header', () => { + const win = new Window({ + width: 20, + height: 10, + closable: true, + maximizable: true, + minimizable: true, + }); + // Update widget rect so size-dependent offsets resolve correctly + win.updateRect({ x: 0, y: 0, width: 20, height: 10 }); + + // Right border is at x=19. Closable at x=18, Maximizable at x=16, Minimizable at x=14 + expect(win.getClickTarget(18, 0)).toBe('close'); + expect(win.getClickTarget(16, 0)).toBe('maximize'); + expect(win.getClickTarget(14, 0)).toBe('minimize'); + expect(win.getClickTarget(5, 0)).toBe('title'); + expect(win.getClickTarget(5, 1)).toBeNull(); + }); + }); + + describe('WindowManager rendering and z-stack', () => { + it('adds windows and orders them on front click', () => { + const wm = new WindowManager(); + const win1 = new Window({ title: 'Win 1' }); + const win2 = new Window({ title: 'Win 2' }); + + wm.addWindow(win1); + wm.addWindow(win2); + + expect(wm.children[0]).toBe(win1); + expect(wm.children[1]).toBe(win2); + + // Trigger mouse down simulating a click on win1 to bring to front + win1.updateRect({ x: 0, y: 0, width: 10, height: 10 }); + win2.updateRect({ x: 15, y: 15, width: 10, height: 10 }); + wm.updateRect({ x: 0, y: 0, width: 40, height: 40 }); + + // Simulate mousedown at x=2, y=2 (hitting win1) + const mousedownEvent = { x: 2, y: 2, type: 'mousedown' as const, button: 'left' as const }; + (wm as any)._handleGlobalMouse(mousedownEvent); + + expect(wm.children[0]).toBe(win2); + expect(wm.children[1]).toBe(win1); + expect(win1.isFocused).toBe(true); + expect(win2.isFocused).toBe(false); + }); + + it('syncs window content positions', () => { + vi.spyOn(caps, 'unicode', 'get').mockReturnValue(true); + const wm = new WindowManager(); + const win = new Window({ title: 'Test', x: 2, y: 3, width: 10, height: 10 }); + const txt = new Text('Inner text', { flexGrow: 1 }); + win.addChild(txt); + wm.addWindow(win); + + wm.updateRect({ x: 0, y: 0, width: 40, height: 40 }); + wm.syncLayout(); + + // Window absolute position should be: x = 2, y = 3 + expect(win.rect).toEqual({ x: 2, y: 3, width: 10, height: 10 }); + + // Content area should start at: x = 2+1 = 3, y = 3+2 = 5 + expect(txt.rect).toEqual({ x: 3, y: 5, width: 8, height: 7 }); + }); + }); +}); diff --git a/packages/ui/src/Window.ts b/packages/ui/src/Window.ts new file mode 100644 index 000000000..bbd5275c2 --- /dev/null +++ b/packages/ui/src/Window.ts @@ -0,0 +1,261 @@ +import { Widget } from '@termuijs/widgets'; +import { + type Style, + type Screen, + type Rect, + type MouseEvent as TermMouseEvent, + mergeStyles, + defaultStyle, + styleToCellAttrs, + getBorderChars, + caps, +} from '@termuijs/core'; + +export interface WindowOptions { + title?: string; + x?: number; + y?: number; + width?: number; + height?: number; + minWidth?: number; + minHeight?: number; + draggable?: boolean; + resizable?: boolean; + minimizable?: boolean; + maximizable?: boolean; + closable?: boolean; +} + +export class Window extends Widget { + public windowX: number; + public windowY: number; + public windowWidth: number; + public windowHeight: number; + + private _title: string; + private _minWidth: number; + private _minHeight: number; + private _draggable: boolean; + private _resizable: boolean; + private _minimizable: boolean; + private _maximizable: boolean; + private _closable: boolean; + + public isMinimized = false; + public isMaximized = false; + public isClosed = false; + + // Track original bounds before maximization + private _prevX = 0; + private _prevY = 0; + private _prevWidth = 0; + private _prevHeight = 0; + + constructor(options: WindowOptions = {}, style?: Partial