From 1b1de76ab0ffcde744540821f6db958056a8d0d1 Mon Sep 17 00:00:00 2001 From: skie1997 Date: Tue, 21 Apr 2026 14:59:50 +0800 Subject: [PATCH 01/18] chore: memo leak problem of ticker. fix#2075 --- ...-release-1.0.11-alpha.1_2026-04-21-06-59.json | 10 ++++++++++ ...-release-1.0.11-alpha.1_2026-04-21-06-59.json | 10 ++++++++++ ...-release-1.0.11-alpha.1_2026-04-21-06-59.json | 10 ++++++++++ ...-release-1.0.11-alpha.1_2026-04-21-06-59.json | 10 ++++++++++ ...-release-1.0.11-alpha.1_2026-04-21-06-59.json | 10 ++++++++++ ...-release-1.0.11-alpha.1_2026-04-21-06-59.json | 10 ++++++++++ ...-release-1.0.11-alpha.1_2026-04-21-06-59.json | 10 ++++++++++ .../vrender-animate/src/ticker/default-ticker.ts | 16 ++++++++++++++-- 8 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 common/changes/@visactor/react-vrender-utils/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json create mode 100644 common/changes/@visactor/react-vrender/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json create mode 100644 common/changes/@visactor/vrender-animate/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json create mode 100644 common/changes/@visactor/vrender-components/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json create mode 100644 common/changes/@visactor/vrender-core/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json create mode 100644 common/changes/@visactor/vrender-kits/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json create mode 100644 common/changes/@visactor/vrender/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json diff --git a/common/changes/@visactor/react-vrender-utils/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json b/common/changes/@visactor/react-vrender-utils/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json new file mode 100644 index 000000000..f3e2462d4 --- /dev/null +++ b/common/changes/@visactor/react-vrender-utils/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/react-vrender-utils", + "comment": "performance: memo leak problem of ticker. fix#2075", + "type": "none" + } + ], + "packageName": "@visactor/react-vrender-utils" +} \ No newline at end of file diff --git a/common/changes/@visactor/react-vrender/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json b/common/changes/@visactor/react-vrender/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json new file mode 100644 index 000000000..e3305b32f --- /dev/null +++ b/common/changes/@visactor/react-vrender/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/react-vrender", + "comment": "performance: memo leak problem of ticker. fix#2075", + "type": "none" + } + ], + "packageName": "@visactor/react-vrender" +} \ No newline at end of file diff --git a/common/changes/@visactor/vrender-animate/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json b/common/changes/@visactor/vrender-animate/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json new file mode 100644 index 000000000..ff1d18f93 --- /dev/null +++ b/common/changes/@visactor/vrender-animate/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vrender-animate", + "comment": "performance: memo leak problem of ticker. fix#2075", + "type": "none" + } + ], + "packageName": "@visactor/vrender-animate" +} \ No newline at end of file diff --git a/common/changes/@visactor/vrender-components/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json b/common/changes/@visactor/vrender-components/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json new file mode 100644 index 000000000..c66909514 --- /dev/null +++ b/common/changes/@visactor/vrender-components/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vrender-components", + "comment": "performance: memo leak problem of ticker. fix#2075", + "type": "none" + } + ], + "packageName": "@visactor/vrender-components" +} \ No newline at end of file diff --git a/common/changes/@visactor/vrender-core/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json b/common/changes/@visactor/vrender-core/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json new file mode 100644 index 000000000..45eb7a116 --- /dev/null +++ b/common/changes/@visactor/vrender-core/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vrender-core", + "comment": "performance: memo leak problem of ticker. fix#2075", + "type": "none" + } + ], + "packageName": "@visactor/vrender-core" +} \ No newline at end of file diff --git a/common/changes/@visactor/vrender-kits/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json b/common/changes/@visactor/vrender-kits/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json new file mode 100644 index 000000000..f188a4836 --- /dev/null +++ b/common/changes/@visactor/vrender-kits/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vrender-kits", + "comment": "performance: memo leak problem of ticker. fix#2075", + "type": "none" + } + ], + "packageName": "@visactor/vrender-kits" +} \ No newline at end of file diff --git a/common/changes/@visactor/vrender/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json b/common/changes/@visactor/vrender/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json new file mode 100644 index 000000000..e24753093 --- /dev/null +++ b/common/changes/@visactor/vrender/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vrender", + "comment": "performance: memo leak problem of ticker. fix#2075", + "type": "none" + } + ], + "packageName": "@visactor/vrender" +} \ No newline at end of file diff --git a/packages/vrender-animate/src/ticker/default-ticker.ts b/packages/vrender-animate/src/ticker/default-ticker.ts index be842f0f2..dc4997722 100644 --- a/packages/vrender-animate/src/ticker/default-ticker.ts +++ b/packages/vrender-animate/src/ticker/default-ticker.ts @@ -4,6 +4,9 @@ import { application, PerformanceRAF, type ITickHandler, type ITicker, STATUS } const performanceRAF = new PerformanceRAF(); +// Avoid unbounded growth when long-running animations keep a ticker alive. +const MAX_FRAME_TIME_HISTORY = 600; + class RAFTickHandler implements ITickHandler { protected released: boolean = false; @@ -44,6 +47,14 @@ export class DefaultTicker extends EventEmitter implements ITicker { declare _lastTickTime: number; protected frameTimeHistory: number[] = []; + protected pushFrameTime(time: number): void { + this.frameTimeHistory.push(time); + const overflow = this.frameTimeHistory.length - MAX_FRAME_TIME_HISTORY; + if (overflow > 0) { + this.frameTimeHistory.splice(0, overflow); + } + } + constructor(stage?: IStage) { super(); this.init(); @@ -216,6 +227,7 @@ export class DefaultTicker extends EventEmitter implements ITicker { release(): void { this.stop(); this.timelines = []; + this.frameTimeHistory.length = 0; this.tickerHandler?.release(); this.tickerHandler = null; this.lastFrameTime = -1; @@ -246,7 +258,7 @@ export class DefaultTicker extends EventEmitter implements ITicker { if (this.lastFrameTime < 0) { this.lastFrameTime = currentTime - this.interval + this.timeOffset; - this.frameTimeHistory.push(this.lastFrameTime); + this.pushFrameTime(this.lastFrameTime); } const delta = currentTime - this.lastFrameTime; @@ -256,7 +268,7 @@ export class DefaultTicker extends EventEmitter implements ITicker { if (!skip) { this._handlerTick(delta); this.lastFrameTime = currentTime; - this.frameTimeHistory.push(this.lastFrameTime); + this.pushFrameTime(this.lastFrameTime); } if (!once) { From ceecfe1b3490a37b02010f9afb5617eb3cca6ec2 Mon Sep 17 00:00:00 2001 From: xile611 Date: Wed, 16 Sep 2026 11:00:03 +0800 Subject: [PATCH 02/18] fix(components): initialize brush mask with computed points Compute rect and axis brush geometry on the first pointer move so the initial mask is a closed rectangle. Preserve polygon trajectories and draw event timing. Validation: 9 targeted tests; all 127 component tests; component typecheck; scoped ESLint. All 16 source-based browser scenarios passed. All four rows of the original VChart case showed rectangular initial strokes and correct zoom ranges: 36 strokes, no diagonals. --- .../__tests__/unit/brush-initial-mask.test.ts | 193 ++++++++++++++++++ .../vrender-components/src/brush/brush.ts | 2 +- 2 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 packages/vrender-components/__tests__/unit/brush-initial-mask.test.ts diff --git a/packages/vrender-components/__tests__/unit/brush-initial-mask.test.ts b/packages/vrender-components/__tests__/unit/brush-initial-mask.test.ts new file mode 100644 index 000000000..72361b9ed --- /dev/null +++ b/packages/vrender-components/__tests__/unit/brush-initial-mask.test.ts @@ -0,0 +1,193 @@ +import { + EventManager, + FederatedPointerEvent, + type CustomEvent, + type IEventTarget, + type IPolygon, + type Stage +} from '@visactor/vrender-core'; +import { Brush } from '../../src'; +import type { BrushAttributes } from '../../src/brush/type'; +import { createCanvas, removeDom } from '../util/dom'; +import { createTestStage } from '../util/vrender'; + +describe('Brush initial mask', () => { + let canvas: HTMLCanvasElement; + let stage: Stage; + let manager: EventManager; + + beforeEach(() => { + canvas = createCanvas(document.body, 'brush-initial-mask'); + stage = createTestStage('brush-initial-mask'); + manager = new EventManager(stage as unknown as IEventTarget, {}); + // 隔离 jsdom 的 DOM 尺寸,不 mock Brush 几何或 pointer 事件处理。 + jest.spyOn(stage, 'eventPointTransform').mockImplementation(event => { + const pointer = event as PointerEvent; + return { x: pointer.clientX, y: pointer.clientY }; + }); + }); + + afterEach(() => { + manager.release(); + stage.release(); + removeDom(canvas); + jest.restoreAllMocks(); + }); + + function pointer(type: string, x: number, y: number) { + const event = new FederatedPointerEvent(manager); + event.type = type; + event.pointerId = 1; + event.pointerType = 'mouse'; + event.button = 0; + event.buttons = type === 'pointerup' ? 0 : 1; + event.client.x = x; + event.client.y = y; + stage.dispatchEvent(event); + } + + function setup(attributes: Partial = {}) { + const brush = new Brush({ + brushType: 'rect', + delayTime: 0, + interactiveRange: { minX: 0, maxX: 200, minY: 0, maxY: 200 }, + xRange: [10, 150], + yRange: [15, 140], + ...attributes + }); + let mask: IPolygon; + const events: string[] = []; + brush.addEventListener('drawStart', (event: CustomEvent) => { + mask = event.detail.operateMask; + }); + ['drawStart', 'brushActive', 'drawing', 'drawEnd'].forEach(type => { + brush.addEventListener(type, () => events.push(type)); + }); + stage.defaultLayer.add(brush as any); + stage.render(); + return { getMask: () => mask, events }; + } + + test.each([ + [ + 'rect', + [20, 30], + [80, 100], + [ + [20, 30], + [80, 30], + [80, 100], + [20, 100] + ] + ], + [ + 'rect', + [80, 100], + [20, 30], + [ + [80, 100], + [20, 100], + [20, 30], + [80, 30] + ] + ], + [ + 'x', + [20, 30], + [80, 100], + [ + [20, 15], + [80, 15], + [80, 140], + [20, 140] + ] + ], + [ + 'x', + [80, 100], + [20, 30], + [ + [80, 15], + [20, 15], + [20, 140], + [80, 140] + ] + ], + [ + 'y', + [20, 30], + [80, 100], + [ + [10, 30], + [10, 100], + [150, 100], + [150, 30] + ] + ], + [ + 'y', + [80, 100], + [20, 30], + [ + [10, 100], + [10, 30], + [150, 30], + [150, 100] + ] + ] + ] as [NonNullable, number[], number[], number[][]][])( + '%s has the correct mask after exactly one pointermove (%j -> %j)', + (brushType, start, end, expected) => { + const { getMask, events } = setup({ brushType }); + pointer('pointerdown', start[0], start[1]); + pointer('pointermove', end[0], end[1]); + stage.render(); + expect(getMask().attribute.points).toEqual(expected.map(([x, y]) => ({ x, y }))); + expect(events).toEqual(['drawStart']); + // 没有第二次移动就松手:结束时不能遗留两点遮罩。 + pointer('pointerup', end[0], end[1]); + expect(getMask().attribute.points).toEqual(expected.map(([x, y]) => ({ x, y }))); + expect(events).toEqual(['drawStart', 'drawEnd']); + } + ); + + test('keeps the polygon trajectory and its previous points snapshot', () => { + const { getMask } = setup({ brushType: 'polygon' }); + pointer('pointerdown', 20, 30); + pointer('pointermove', 80, 100); + const initialPoints = getMask().attribute.points; + expect(initialPoints).toEqual([ + { x: 20, y: 30 }, + { x: 80, y: 100 } + ]); + pointer('pointermove', 110, 60); + expect(initialPoints).toEqual([ + { x: 20, y: 30 }, + { x: 80, y: 100 } + ]); + expect(getMask().attribute.points).toEqual([ + { x: 20, y: 30 }, + { x: 80, y: 100 }, + { x: 110, y: 60 } + ]); + pointer('pointerup', 110, 60); + }); + + test('preserves the draw event sequence and subsequent rectangle updates', () => { + const { getMask, events } = setup(); + pointer('pointerdown', 20, 30); + pointer('pointermove', 80, 100); + expect(events).toEqual(['drawStart']); + pointer('pointermove', 100, 110); + expect(events).toEqual(['drawStart', 'brushActive']); + pointer('pointermove', 120, 130); + pointer('pointerup', 120, 130); + expect(events).toEqual(['drawStart', 'brushActive', 'drawing', 'drawEnd']); + expect(getMask().attribute.points).toEqual([ + { x: 20, y: 30 }, + { x: 120, y: 30 }, + { x: 120, y: 130 }, + { x: 20, y: 130 } + ]); + }); +}); diff --git a/packages/vrender-components/src/brush/brush.ts b/packages/vrender-components/src/brush/brush.ts index 07631322c..f675a78f7 100644 --- a/packages/vrender-components/src/brush/brush.ts +++ b/packages/vrender-components/src/brush/brush.ts @@ -464,7 +464,7 @@ export class Brush extends AbstractComponent> { private _addBrushMask() { const { brushStyle, hasMask } = this.attribute as BrushAttributes; const brushMask = graphicCreator.polygon({ - points: cloneDeep(this._cacheDrawPoints), // _cacheDrawPoints在不断更新,所以这里需要cloneDeep + points: this._computeMaskPoints(), cursor: 'move', pickable: false, ...brushStyle, From 7f4f46517547df838cc7e3cbd3c49d9f637682f3 Mon Sep 17 00:00:00 2001 From: xile611 Date: Tue, 15 Sep 2026 17:33:37 +0800 Subject: [PATCH 03/18] fix: unify glyph state resolution and preserve legacy ordering --- .../unit/graphic/glyph-state.test.ts | 80 ++++++++++++- packages/vrender-core/src/graphic/glyph.ts | 106 +++++++++++------- packages/vrender-core/src/graphic/graphic.ts | 22 ++-- .../src/graphic/state/state-definition.ts | 2 + .../src/graphic/state/state-engine.ts | 5 + 5 files changed, 168 insertions(+), 47 deletions(-) diff --git a/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts b/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts index 137dd434c..3687dbada 100644 --- a/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts @@ -1,5 +1,6 @@ import { createGlyph } from '../../../src/graphic/glyph'; import { createRect } from '../../../src/graphic/rect'; +import { createGroup } from '../../../src/graphic/group'; describe('Glyph state', () => { const createTestGlyph = () => { @@ -115,7 +116,7 @@ describe('Glyph state', () => { expect(glyph.normalAttrs).toEqual((glyph as any).baseAttributes); }); - test('should differ from normal graphic states by reading glyphStates instead of states', () => { + test('explicit glyphStates take precedence over standard local definitions', () => { const { glyph } = createTestGlyph(); (glyph as any).states = { hover: { @@ -135,4 +136,81 @@ describe('Glyph state', () => { expect(glyph.attribute.stroke).toBe('glyph-state'); }); + + test('removes state-only keys and restores the latest base attributes', () => { + const { glyph } = createTestGlyph(); + glyph.glyphStates = { + selected: { attributes: { fillOpacity: 0.25, stroke: 'red' }, subAttributes: [] } + }; + glyph.setStates(['selected'], false); + expect(glyph.attribute.fillOpacity).toBe(0.25); + expect(glyph.baseAttributes.fillOpacity).toBeUndefined(); + glyph.setAttribute('stroke', 'orange'); + expect(glyph.attribute.stroke).toBe('red'); + glyph.setStates([], false); + expect(glyph.attribute.stroke).toBe('orange'); + expect(glyph.attribute.fillOpacity).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(glyph.attribute, 'fillOpacity')).toBe(false); + }); + + test('refreshes a proxy-only state without clearing it first', () => { + const { glyph } = createTestGlyph(); + let opacity = 0.2; + glyph.glyphStateProxy = () => ({ attributes: { fillOpacity: opacity }, subAttributes: [] }); + glyph.setStates(['selected'], { animate: false }); + opacity = 0.8; + glyph.setStates(['selected'], { animate: false }); + expect(glyph.currentStates).toEqual(['selected']); + expect(glyph.effectiveStates).toEqual(['selected']); + expect(glyph.resolvedStatePatch.fillOpacity).toBe(0.8); + expect(glyph.attribute.fillOpacity).toBe(0.8); + expect(glyph.baseAttributes.fillOpacity).toBeUndefined(); + }); + + test('preserves legacy input order and stateSort without mutating the input', () => { + const { glyph } = createTestGlyph(); + glyph.glyphStates = { + a: { attributes: { stroke: 'red' }, subAttributes: [] }, + z: { attributes: { stroke: 'blue' }, subAttributes: [] } + }; + glyph.useStates(['z', 'a'], false); + expect(glyph.attribute.stroke).toBe('red'); + glyph.useStates(['a', 'z'], false); + expect(glyph.attribute.stroke).toBe('blue'); + (glyph as any).stateSort = (a: string, b: string) => b.localeCompare(a); + const states = ['a', 'z']; + const proxy = jest.fn((name: string) => glyph.glyphStates[name]); + glyph.glyphStateProxy = proxy; + glyph.setStates(states, { animate: false }); + expect(glyph.attribute.stroke).toBe('red'); + expect(proxy).toHaveBeenCalledWith('a', ['z', 'a']); + expect(states).toEqual(['a', 'z']); + }); + + test('uses Group definitions unless explicit legacy inputs own the glyph', () => { + const { glyph } = createTestGlyph(); + const group = createGroup({}); + group.sharedStateDefinitions = { + hover: { stroke: 'shared' }, + selected: { fillOpacity: 0.4 } + }; + group.add(glyph); + glyph.states = { hover: { stroke: 'local' } }; + glyph.setStates(['hover'], false); + expect(glyph.attribute.stroke).toBe('shared'); + glyph.glyphStates = { hover: { attributes: { stroke: 'legacy' }, subAttributes: [] } }; + glyph.setStates(['hover', 'selected'], { animate: false }); + expect(glyph.attribute.stroke).toBe('legacy'); + expect(glyph.attribute.fillOpacity).toBeUndefined(); + glyph.glyphStateProxy = () => undefined; + glyph.setStates(['hover'], { animate: false }); + expect(glyph.attribute.stroke).toBe('black'); + glyph.glyphStateProxy = undefined; + glyph.glyphStates = undefined; + glyph.setStates(['hover', 'selected'], { animate: false }); + expect(glyph.attribute.stroke).toBe('shared'); + expect(glyph.attribute.fillOpacity).toBe(0.4); + glyph.clearStates(false); + expect(glyph.registeredActiveScopes).toBeUndefined(); + }); }); diff --git a/packages/vrender-core/src/graphic/glyph.ts b/packages/vrender-core/src/graphic/glyph.ts index 14a629996..d113ec48a 100644 --- a/packages/vrender-core/src/graphic/glyph.ts +++ b/packages/vrender-core/src/graphic/glyph.ts @@ -8,6 +8,9 @@ import type { IGraphicAttribute, ISetAttributeContext } from '../interface'; +import { StateDefinitionCompiler } from './state/state-definition-compiler'; +import type { CompiledStateDefinition, StateDefinition, StateDefinitionsInput } from './state/state-definition'; +import type { SharedStateScope } from './state/shared-state-scope'; import { getTheme } from './theme'; import { GLYPH_NUMBER_TYPE } from './constants'; @@ -30,6 +33,10 @@ export class Glyph extends Graphic implements IGlyph { subAttributes: Partial[]; }; protected declare subGraphic: IGraphic[]; + private legacyDefinitionsSource?: Glyph['glyphStates']; + private legacyProxySource?: Glyph['glyphStateProxy']; + private legacyDefinitions?: StateDefinitionsInput; + private legacyCompiledDefinitions?: Map>; static NOWORK_ANIMATE_ATTR = NOWORK_ANIMATE_ATTR; @@ -189,54 +196,75 @@ export class Glyph extends Graphic implements IGlyph { return false; } - useStates(states: string[], hasAnimation?: boolean): void { - if (!states.length) { - this.clearStates(hasAnimation); - return; + protected hasLegacyStateDefinitions(): boolean { + if (this.glyphStateProxy) { + return true; } - const previousStates = this.currentStates ? this.currentStates.slice() : []; - - const isChange = - this.currentStates?.length !== states.length || - states.some((stateName, index) => this.currentStates[index] !== stateName); - if (!isChange) { - return; + for (const name in this.glyphStates) { + if (Object.prototype.hasOwnProperty.call(this.glyphStates, name)) { + return true; + } } + return false; + } - this.stopStateAnimates(); + protected syncSharedStateScopeBindingFromTree( + markDirty: boolean = true, + inheritedSharedStateScope?: SharedStateScope> | null + ): boolean { + // Legacy Glyph definitions historically own the whole state surface. + return this.hasLegacyStateDefinitions() + ? this.syncSharedStateScopeBinding(undefined, markDirty) + : super.syncSharedStateScopeBindingFromTree(markDirty, inheritedSharedStateScope); + } - if (this.stateSort) { - states = states.sort(this.stateSort); + protected resolveEffectiveCompiledDefinitions(stateNames: readonly string[] = []) { + if (!this.hasLegacyStateDefinitions()) { + this.legacyDefinitions = undefined; + this.legacyCompiledDefinitions = undefined; + return super.resolveEffectiveCompiledDefinitions(stateNames); } - const stateAttrs = {}; - states.forEach(stateName => { - const attrs = this.glyphStateProxy ? this.glyphStateProxy(stateName, states) : this.glyphStates[stateName]; - - if (attrs) { - Object.assign(stateAttrs, attrs.attributes); + this.syncSharedStateScopeBindingFromTree(false); + let changed = false; + if ( + !this.legacyDefinitions || + this.legacyDefinitionsSource !== this.glyphStates || + this.legacyProxySource !== this.glyphStateProxy + ) { + this.legacyDefinitionsSource = this.glyphStates; + this.legacyProxySource = this.glyphStateProxy; + this.legacyDefinitions = {}; + for (const name of Object.keys(this.glyphStates ?? {})) { + this.legacyDefinitions[name] = this.createLegacyStateDefinition(name); } - }); - - if (!this.beforeStateUpdate(stateAttrs, previousStates, states, hasAnimation, false)) { - return; + changed = true; } - - this.currentStates = states; - this.applyStateAttrs(stateAttrs, states, hasAnimation); + if (this.glyphStateProxy) { + const addDefinition = (name: string) => { + if (!Object.prototype.hasOwnProperty.call(this.legacyDefinitions, name)) { + this.legacyDefinitions[name] = this.createLegacyStateDefinition(name); + changed = true; + } + }; + this.currentStates?.forEach(addDefinition); + stateNames.forEach(addDefinition); + } + if (changed) { + this.legacyCompiledDefinitions = new StateDefinitionCompiler().compile( + this.legacyDefinitions + ); + } + return { compiledDefinitions: this.legacyCompiledDefinitions, stateOrder: 'input' as const }; } - clearStates(hasAnimation?: boolean) { - this.stopStateAnimates(); - const previousStates = this.currentStates ? this.currentStates.slice() : []; - if (this.hasState() && this.normalAttrs) { - if (!this.beforeStateUpdate(this.normalAttrs, previousStates, [], hasAnimation, true)) { - return; - } - this.currentStates = []; - this.applyStateAttrs(this.normalAttrs, this.currentStates, hasAnimation, true); - } else { - this.currentStates = []; - } + private createLegacyStateDefinition(name: string): StateDefinition { + return this.glyphStateProxy + ? { + name, + resolver: ({ graphic, activeStates }) => + (graphic as Glyph).glyphStateProxy(name, activeStates as string[])?.attributes + } + : { name, patch: this.glyphStates[name].attributes }; } clone(): IGraphic> { diff --git a/packages/vrender-core/src/graphic/graphic.ts b/packages/vrender-core/src/graphic/graphic.ts index e6d184533..67eb93c13 100644 --- a/packages/vrender-core/src/graphic/graphic.ts +++ b/packages/vrender-core/src/graphic/graphic.ts @@ -463,6 +463,7 @@ export abstract class Graphic = Partial; protected stateEngineCompiledDefinitions?: Map>; protected stateEngineStateSort?: (stateA: string, stateB: string) => number; + protected stateEngineStateOrder?: 'input'; protected stateEngineMergeMode?: StateMergeMode; protected stateTransitionOrchestrator?: StateTransitionOrchestrator; protected localStateDefinitionsSource?: StateDefinitionsInput; @@ -686,8 +687,9 @@ export abstract class Graphic = Partial>; + stateOrder?: 'input'; } { this.syncSharedStateScopeBindingFromTree(false); const boundScope = this.boundSharedStateScope; @@ -2082,8 +2084,11 @@ export abstract class Graphic = Partial = this.getStateResolveBaseAttrs()) { - const { compiledDefinitions } = this.resolveEffectiveCompiledDefinitions(); + protected ensureStateEngine( + stateResolveBaseAttrs: Partial = this.getStateResolveBaseAttrs(), + stateNames: readonly string[] = this.currentStates ?? EMPTY_STATE_NAMES + ) { + const { compiledDefinitions, stateOrder } = this.resolveEffectiveCompiledDefinitions(stateNames); this.compiledStateDefinitions = compiledDefinitions; if (!compiledDefinitions) { @@ -2093,15 +2098,18 @@ export abstract class Graphic = Partial({ compiledDefinitions, stateSort: this.stateSort, + stateOrder, mergeMode: this.stateMergeMode }); this.stateEngineCompiledDefinitions = compiledDefinitions; this.stateEngineStateSort = this.stateSort; + this.stateEngineStateOrder = stateOrder; this.stateEngineMergeMode = this.stateMergeMode; } @@ -2157,7 +2165,7 @@ export abstract class Graphic = Partial = this.getStateResolveBaseAttrs() ): GraphicStateTransition { - const stateEngine = this.ensureStateEngine(stateResolveBaseAttrs); + const stateEngine = this.ensureStateEngine(stateResolveBaseAttrs, states); return stateEngine ? this.toGraphicStateTransition(stateEngine.applyStates(states)) : this.resolveLocalUseStatesTransition(states); @@ -2171,7 +2179,7 @@ export abstract class Graphic = Partial = Partial = Partial { const stateResolveBaseAttrs = this.getStateResolveBaseAttrs(); - const stateEngine = this.ensureStateEngine(stateResolveBaseAttrs); + const stateEngine = this.ensureStateEngine(stateResolveBaseAttrs, states); if (forceResolverRefresh) { stateEngine?.invalidateResolverCache(); } diff --git a/packages/vrender-core/src/graphic/state/state-definition.ts b/packages/vrender-core/src/graphic/state/state-definition.ts index df70c2ded..3874bdd63 100644 --- a/packages/vrender-core/src/graphic/state/state-definition.ts +++ b/packages/vrender-core/src/graphic/state/state-definition.ts @@ -46,5 +46,7 @@ export type StateDefinitionsInput = Record = Record> { compiledDefinitions: Map>; stateSort?: (a: string, b: string) => number; + /** @internal Legacy Glyph inputs merge in requested order, after stateSort. */ + stateOrder?: 'input'; mergeMode?: StateMergeMode; } diff --git a/packages/vrender-core/src/graphic/state/state-engine.ts b/packages/vrender-core/src/graphic/state/state-engine.ts index 1046f428e..6434c8054 100644 --- a/packages/vrender-core/src/graphic/state/state-engine.ts +++ b/packages/vrender-core/src/graphic/state/state-engine.ts @@ -37,6 +37,7 @@ function deepMerge(base: Record, value: Record): Recor export class StateEngine = Record> { private readonly compiledDefinitions: Map>; private readonly stateSort?: (a: string, b: string) => number; + private readonly stateOrder?: 'input'; private readonly mergeMode: 'shallow' | 'deep'; private _activeStates: string[] = []; @@ -52,6 +53,7 @@ export class StateEngine = Record> { constructor(options: IStateEngineOptions) { this.compiledDefinitions = options.compiledDefinitions; this.stateSort = options.stateSort; + this.stateOrder = options.stateOrder; this.mergeMode = options.mergeMode ?? 'shallow'; } @@ -195,6 +197,9 @@ export class StateEngine = Record> { } private sortStates(states: string[]): string[] { + if (this.stateOrder === 'input') { + return this.stateSort ? states.sort(this.stateSort) : states; + } const withDefinition: string[] = []; const withoutDefinition: string[] = []; From 0fc68ae6011d88df59f77633161c7453a6d90b56 Mon Sep 17 00:00:00 2001 From: xile611 Date: Tue, 15 Sep 2026 17:40:24 +0800 Subject: [PATCH 04/18] fix: synchronize glyph children across attribute and animation commits --- .../fix-glyph-state-20260915.json | 10 ++ .../state-engine/GLYPH_STATE_CONTRACT.md | 25 +++ .../unit/animation-runtime-attribute.test.ts | 37 ++++ .../unit/graphic/glyph-update.test.ts | 127 ++++++++++++++ .../unit/graphic/state-animation.test.ts | 2 +- packages/vrender-core/src/graphic/glyph.ts | 166 ++++++++++-------- packages/vrender-core/src/graphic/graphic.ts | 35 +++- .../src/interface/graphic/glyph.ts | 14 +- 8 files changed, 343 insertions(+), 73 deletions(-) create mode 100644 common/changes/@visactor/vrender-core/fix-glyph-state-20260915.json create mode 100644 docs/refactor/state-engine/GLYPH_STATE_CONTRACT.md create mode 100644 packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts diff --git a/common/changes/@visactor/vrender-core/fix-glyph-state-20260915.json b/common/changes/@visactor/vrender-core/fix-glyph-state-20260915.json new file mode 100644 index 000000000..2efe59151 --- /dev/null +++ b/common/changes/@visactor/vrender-core/fix-glyph-state-20260915.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vrender-core", + "comment": "统一 Glyph 状态生命周期,保留旧状态覆盖顺序,补齐派生子图形同步与属性撤销,并修复内部中断状态动画污染基础属性的问题。", + "type": "patch" + } + ], + "packageName": "@visactor/vrender-core" +} diff --git a/docs/refactor/state-engine/GLYPH_STATE_CONTRACT.md b/docs/refactor/state-engine/GLYPH_STATE_CONTRACT.md new file mode 100644 index 000000000..23f758b5f --- /dev/null +++ b/docs/refactor/state-engine/GLYPH_STATE_CONTRACT.md @@ -0,0 +1,25 @@ +# Glyph 状态与派生属性契约 + +Glyph 与普通 Graphic 共用 `baseAttributes + resolvedStatePatch -> attribute`、同状态刷新、状态动画及清空路径。 + +## 定义来源 + +- 配置 `glyphStateProxy` 时,由 proxy 决定完整状态贡献;返回空值不回退 `glyphStates` 或 Group。 +- 无 proxy、配置非空 `glyphStates` 时,读取其 `.attributes`;`subAttributes` 不自动传播。 +- 旧输入按目标状态列表顺序合并,配置 `stateSort` 时先排序,后面的状态覆盖前面的状态;不修改调用方数组。 +- 没有旧输入时,完全使用标准状态定义与 Group-first、priority/rank 规则。旧输入与 Group 不隐式逐状态混合。 +- 动态值变化但状态名不变时,用 `setStates(names, { animate: false })` 刷新;需要动画时同时设置 `animate` 和 `animateSameStatePatchChange`。 + +## 派生图形 + +子图形和编码上下文准备好后调用 `setSubGraphicEncoder(encoder)`。注册时立即同步一次,后续回调读取已提交的 `glyph.attribute`,包括基础更新、状态恢复及动画中间帧。编码器仅修改子图形,不修改宿主属性或宿主状态。 + +`commitSubGraphicAttributes(child, patch, removedKeys, context)` 在一次提交中更新值并删除已经撤销的 own keys,同时维护子图形基础属性、状态、更新标记及继承关系。上层负责输出键归属;删除后可重新读取当前宿主继承值。不要直接删除 `child.attribute` 的键,也不要用写入 `undefined` 代替属性删除。 + +更新顺序为:宿主提交、继承绑定、派生同步、外部通知。`skipUpdateCallback` 跳过观察回调和服务通知,但不跳过派生同步;编码器应将 context 传给子图形提交。`onUpdate` 属于观察回调。 + +clone 保留已编码外观,不复制宿主编码器;独立使用的 clone 应自行注册。release 解除编码器、子图形继承关系并释放子图形。 + +## 动画中断 + +内部切换/取消状态停止旧动画后,由状态系统恢复静态真值,不将旧动画终值提交为基础属性。公开 `animate.stop('start' | 'end' | attrs)` 仍是显式静态提交 API。 diff --git a/packages/vrender-animate/__tests__/unit/animation-runtime-attribute.test.ts b/packages/vrender-animate/__tests__/unit/animation-runtime-attribute.test.ts index 12fa7853f..8fd5a8739 100644 --- a/packages/vrender-animate/__tests__/unit/animation-runtime-attribute.test.ts +++ b/packages/vrender-animate/__tests__/unit/animation-runtime-attribute.test.ts @@ -2,6 +2,7 @@ import { application, AttributeUpdateType, createGroup, + createGlyph, createLine, createRect, createSymbol, @@ -124,6 +125,42 @@ describe('D3 pre-handoff animation runtime', () => { jest.restoreAllMocks(); }); + test('Glyph children follow actual animation frames and interrupted state restoration', () => { + const { group, ticker, graphicService } = createStageHarness('glyph-state-runtime'); + const glyph = createGlyph({ width: 20, fill: 'blue' }); + const child = createRect({ height: 10 }); + bindGraphicService(glyph, graphicService); + bindGraphicService(child, graphicService); + glyph.setSubGraphic([child]); + glyph.setSubGraphicEncoder((g, context) => + g.commitSubGraphicAttributes(child, { width: g.attribute.width }, undefined, context) + ); + group.appendChild(glyph); + glyph.states = { selected: { width: 60 } }; + glyph.stateAnimateConfig = { duration: 100, easing: 'linear' }; + glyph.useStates(['selected'], true); + expect(child.attribute.width).toBe(20); + tick(ticker, 50); + expect(child.attribute.width).toBeCloseTo(40); + expect(glyph.baseAttributes.width).toBe(20); + tick(ticker, 50); + expect(child.attribute.width).toBeCloseTo(60); + glyph.clearStates(true); + tick(ticker, 50); + expect(child.attribute.width).toBeCloseTo(40); + tick(ticker, 50); + expect(child.attribute.width).toBe(20); + glyph.useStates(['selected'], true); + tick(ticker, 25); + expect(child.attribute.width).toBeCloseTo(30); + glyph.clearStates(false); + expect({ host: glyph.attribute.width, base: glyph.baseAttributes.width }).toEqual({ host: 20, base: 20 }); + expect(child.attribute.width).toBe(20); + tick(ticker, 100); + expect(child.attribute.width).toBe(20); + expect(glyph.baseAttributes.width).toBe(20); + }); + test('state animation updates graphic.attribute over time without polluting baseAttributes', () => { const { group, ticker, graphicService } = createStageHarness('state-runtime'); const rect = createAnimatedRect(graphicService); diff --git a/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts b/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts new file mode 100644 index 000000000..2fce9b116 --- /dev/null +++ b/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts @@ -0,0 +1,127 @@ +import { createGlyph } from '../../../src/graphic/glyph'; +import { createRect } from '../../../src/graphic/rect'; +import { UpdateTag } from '../../../src/common/enums'; + +const createFixture = () => { + const glyph = createGlyph({ fill: 'red', width: 20 }); + const child = createRect({ height: 10 }); + const service = { onAttributeUpdate: jest.fn(), onSetStage: jest.fn() }; + [glyph, child].forEach(g => jest.spyOn(g as any, 'getGraphicService').mockReturnValue(service)); + glyph.setSubGraphic([child]); + return { glyph, child, service }; +}; + +describe('Glyph derived attributes', () => { + test('encodes the initial, state, base update and restored values before observers', () => { + const { glyph, child } = createFixture(); + const encoder = jest.fn(g => child.setAttribute('width', g.attribute.width)); + glyph.setSubGraphicEncoder(encoder); + expect(child.attribute.width).toBe(20); + const seen: number[] = []; + glyph.onUpdate(() => seen.push(child.attribute.width)); + glyph.states = { selected: { width: 40 } }; + glyph.setStates(['selected'], false); + glyph.setAttribute('width', 30); + glyph.clearStates(false); + expect(seen).toEqual([40, 40, 30]); + expect(glyph.baseAttributes.width).toBe(30); + }); + + test('silent writes still encode but do not notify observers or services', () => { + const { glyph, child, service } = createFixture(); + glyph.setSubGraphicEncoder((g, context) => { + g.commitSubGraphicAttributes(child, { width: g.attribute.width }, undefined, context); + }); + service.onAttributeUpdate.mockClear(); + const observer = jest.fn(); + glyph.onUpdate(observer); + glyph.addEventListener('afterAttributeUpdate', observer); + child.addEventListener('afterAttributeUpdate', observer); + glyph.setAttributes({ width: 50 }, false, { skipUpdateCallback: true }); + expect(child.attribute.width).toBe(50); + expect(observer).not.toHaveBeenCalled(); + expect(service.onAttributeUpdate).not.toHaveBeenCalled(); + }); + + test('keeps inheritance through host and child state surfaces and detach', () => { + const { glyph, child } = createFixture(); + glyph.states = { hover: { fill: 'blue' } }; + glyph.useStates(['hover'], false); + expect(child.attribute.fill).toBe('blue'); + child.states = { selected: { lineWidth: 4 } }; + child.useStates(['selected'], false); + expect(child.attribute.fill).toBe('blue'); + child.setAttribute('height', 15); + expect(child.attribute.fill).toBe('blue'); + glyph.clearStates(false); + expect(child.attribute.fill).toBe('red'); + child.clearStates(false); + expect(child.attribute.fill).toBe('red'); + glyph.setSubGraphic([]); + expect(child.glyphHost).toBeNull(); + expect(child.attribute.fill).toBeUndefined(); + expect(child.baseAttributes.fill).toBeUndefined(); + }); + + test('removes only owned keys atomically and keeps child state and base truth', () => { + const { glyph, child, service } = createFixture(); + child.setAttributes({ fill: 'orange', lineWidth: 2 }); + child.states = { selected: { fill: 'green' } }; + child.setStates(['selected'], false); + glyph.setAttribute('fill', 'blue'); + service.onAttributeUpdate.mockClear(); + glyph.commitSubGraphicAttributes(child, { width: 9 }, ['fill']); + expect(child.attribute.fill).toBe('green'); + expect(child.attribute.width).toBe(9); + expect(child.attribute.lineWidth).toBe(2); + expect(service.onAttributeUpdate).toHaveBeenCalledTimes(1); + child.clearStates(false); + expect(child.attribute.fill).toBe('blue'); + expect(Object.prototype.hasOwnProperty.call(child.baseAttributes, 'fill')).toBe(false); + }); + + test('inherited paint state changes do not invalidate child geometry', () => { + const { glyph, child } = createFixture(); + glyph.states = { hover: { fill: 'blue', fillOpacity: 0.5 } }; + (glyph as any)._updateTag = 0; + (child as any)._updateTag = 0; + glyph.setStates(['hover'], false); + expect(child.attribute.fill).toBe('blue'); + expect((child as any)._updateTag & UpdateTag.UPDATE_PAINT).not.toBe(0); + expect((child as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).toBe(0); + expect((glyph as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).toBe(0); + glyph.clearStates(false); + (glyph as any)._updateTag = 0; + (child as any)._updateTag = 0; + glyph.setAttribute('fill', 'purple'); + expect(child.attribute.fill).toBe('purple'); + expect((child as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).toBe(0); + expect((glyph as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).toBe(0); + }); + + test('clone callbacks are independent and initAttributes resynchronizes children', () => { + const { glyph, child, service } = createFixture(); + const encode = jest.fn((g, context) => { + g.commitSubGraphicAttributes(g.getSubGraphic()[0], { width: g.attribute.width }, undefined, context); + }); + glyph.setSubGraphicEncoder(encode); + const clone = glyph.clone() as typeof glyph; + [clone, ...clone.getSubGraphic()].forEach(g => jest.spyOn(g as any, 'getGraphicService').mockReturnValue(service)); + clone.setAttributes({ x: 10 }, false, { skipUpdateCallback: true }); + expect(encode).toHaveBeenCalledTimes(1); + clone.setSubGraphicEncoder(encode); + clone.initAttributes({ width: 60 }); + expect(clone.getSubGraphic()[0].attribute).toMatchObject({ width: 60 }); + expect(child.attribute.width).toBe(20); + }); + + test('release clears encoder references and detaches children', () => { + const { glyph, child } = createFixture(); + glyph.setSubGraphicEncoder(jest.fn()); + glyph.release(); + expect(glyph.getSubGraphic()).toEqual([]); + expect(child.glyphHost).toBeNull(); + expect(child.releaseStatus).toBe('released'); + expect((glyph as any).subGraphicEncoder).toBeUndefined(); + }); +}); diff --git a/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts b/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts index 5997c0a72..6384c08b6 100644 --- a/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts @@ -217,7 +217,7 @@ describe('Graphic state animation integration', () => { graphic.useStates(['hover'], false); - expect((graphic as any).stopAnimationState).toHaveBeenCalledWith('state', 'end'); + expect((graphic as any).stopAnimationState).toHaveBeenCalledWith('state', undefined); }); test('should allow partial animation config overrides', () => { diff --git a/packages/vrender-core/src/graphic/glyph.ts b/packages/vrender-core/src/graphic/glyph.ts index d113ec48a..14a30554a 100644 --- a/packages/vrender-core/src/graphic/glyph.ts +++ b/packages/vrender-core/src/graphic/glyph.ts @@ -1,4 +1,4 @@ -import type { AABBBounds, IAABBBounds, IPointLike } from '@visactor/vutils'; +import type { AABBBounds, IAABBBounds } from '@visactor/vutils'; import { Graphic, NOWORK_ANIMATE_ATTR } from './graphic'; import type { GraphicType, @@ -12,6 +12,7 @@ import { StateDefinitionCompiler } from './state/state-definition-compiler'; import type { CompiledStateDefinition, StateDefinition, StateDefinitionsInput } from './state/state-definition'; import type { SharedStateScope } from './state/shared-state-scope'; import { getTheme } from './theme'; +import { UpdateCategory } from './state/attribute-update-classifier'; import { GLYPH_NUMBER_TYPE } from './constants'; export class Glyph extends Graphic implements IGlyph { @@ -33,6 +34,7 @@ export class Glyph extends Graphic implements IGlyph { subAttributes: Partial[]; }; protected declare subGraphic: IGraphic[]; + private subGraphicEncoder?: (g: IGlyph, context?: ISetAttributeContext) => void; private legacyDefinitionsSource?: Glyph['glyphStates']; private legacyProxySource?: Glyph['glyphStateProxy']; private legacyDefinitions?: StateDefinitionsInput; @@ -53,16 +55,17 @@ export class Glyph extends Graphic implements IGlyph { this.subGraphic = subGraphic; subGraphic.forEach(g => { g.glyphHost = this; - Object.setPrototypeOf(g.attribute, this.attribute); + Graphic.bindGlyphAttributes(g as Graphic, this.attribute); }); this.valid = this.isValid(); this.addUpdateBoundTag(); + this.subGraphicEncoder?.(this); } protected detachSubGraphic() { this.subGraphic.forEach(g => { g.glyphHost = null; - Object.setPrototypeOf(g.attribute, {}); + Graphic.bindGlyphAttributes(g as Graphic, Object.prototype); }); } @@ -82,84 +85,94 @@ export class Glyph extends Graphic implements IGlyph { return true; } - setAttribute(key: string, value: any, forceUpdateTag?: boolean, context?: ISetAttributeContext) { - super.setAttribute(key, value, forceUpdateTag, context); - this.subGraphic.forEach(g => { - g.addUpdateShapeAndBoundsTag(); - g.addUpdatePositionTag(); - }); + setSubGraphicEncoder(encoder?: (g: IGlyph, context?: ISetAttributeContext) => void): void { + this.subGraphicEncoder = encoder; + encoder?.(this); } - setAttributes( - params: Partial, - forceUpdateTag: boolean = false, + commitSubGraphicAttributes( + subGraphic: IGraphic, + patch: Record, + removedKeys?: readonly string[], context?: ISetAttributeContext - ) { - super.setAttributes(params, forceUpdateTag, context); - this.subGraphic.forEach(g => { - g.addUpdateShapeAndBoundsTag(); - g.addUpdatePositionTag(); - }); - } - - translate(x: number, y: number) { - super.translate(x, y); - - this.subGraphic.forEach(g => { - g.addUpdatePositionTag(); - g.addUpdateBoundTag(); - }); - return this; - } - - translateTo(x: number, y: number) { - super.translateTo(x, y); - - this.subGraphic.forEach(g => { - g.addUpdatePositionTag(); - g.addUpdateBoundTag(); - }); - return this; + ): void { + Graphic.commitDerivedAttributePatch(subGraphic as Graphic, patch, removedKeys, context); } - scale(scaleX: number, scaleY: number, scaleCenter?: IPointLike) { - super.scale(scaleX, scaleY, scaleCenter); - - this.subGraphic.forEach(g => { - g.addUpdatePositionTag(); - g.addUpdateBoundTag(); - }); - return this; + onAttributeUpdate(context?: ISetAttributeContext): void { + if (this.glyphHost) { + Graphic.bindGlyphAttributes(this, this.glyphHost.attribute); + } + for (const child of this.subGraphic) { + Graphic.bindGlyphAttributes(child as Graphic, this.attribute); + } + this.subGraphicEncoder?.(this, context); + if (!context?.skipUpdateCallback) { + this._onUpdate?.(this); + } + super.onAttributeUpdate(context); } - scaleTo(scaleX: number, scaleY: number) { - super.scaleTo(scaleX, scaleY); - - this.subGraphic.forEach(g => { - g.addUpdatePositionTag(); - g.addUpdateBoundTag(); - }); - return this; + protected submitUpdateByCategory(category: UpdateCategory, forceUpdateTag: boolean = false): void { + super.submitUpdateByCategory(category, forceUpdateTag); + for (const child of this.subGraphic) { + if (forceUpdateTag || category & UpdateCategory.SHAPE) { + child.addUpdateShapeAndBoundsTag(); + } else if (category & UpdateCategory.BOUNDS) { + child.addUpdateBoundTag(); + } + if (category & UpdateCategory.PAINT) { + child.addUpdatePaintTag(); + } + if (forceUpdateTag || category & UpdateCategory.TRANSFORM) { + child.addUpdatePositionTag(); + } + if (forceUpdateTag || category & UpdateCategory.LAYOUT) { + child.addUpdateLayoutTag(); + } + } } - rotate(angle: number) { - super.rotate(angle); - - this.subGraphic.forEach(g => { - g.addUpdatePositionTag(); - g.addUpdateBoundTag(); - }); - return this; + // Glyph forwards inherited invalidation to children, so its base fast path must + // classify changed keys too. Ordinary Graphic setters keep their existing path. + protected commitBaseAttributesByTouchedKeys( + params: Partial, + forceUpdateTag: boolean = false, + context?: ISetAttributeContext + ): void { + const base = this.getBaseAttributesStorage(); + let category = UpdateCategory.NONE; + let hasKeys = false; + for (const key in params) { + if (!Object.prototype.hasOwnProperty.call(params, key)) { + continue; + } + hasKeys = true; + const prev = (base as any)[key]; + const next = (params as any)[key]; + if (prev !== next) { + category = this.mergeAttributeDeltaCategory(category, key, prev, next); + } + (base as any)[key] = next; + } + if (!hasKeys) { + return; + } + this.attribute = base; + this._baseAttributes = undefined; + this.attributeMayContainTransientAttrs = false; + this.valid = this.isValid(); + this.submitUpdateByCategory(category, forceUpdateTag); + this.onAttributeUpdate(context); } - rotateTo(angle: number) { - super.rotate(angle); - - this.subGraphic.forEach(g => { - g.addUpdatePositionTag(); - g.addUpdateBoundTag(); - }); - return this; + protected commitBaseAttributeBySingleKey( + key: string, + value: any, + forceUpdateTag: boolean = false, + context?: ISetAttributeContext + ): void { + this.commitBaseAttributesByTouchedKeys({ [key]: value }, forceUpdateTag, context); } getGraphicTheme(): Required { @@ -273,6 +286,19 @@ export class Glyph extends Graphic implements IGlyph { return glyph; } + release(): void { + super.release(); + this.subGraphicEncoder = undefined; + this._onUpdate = undefined; + this.legacyDefinitions = undefined; + this.legacyCompiledDefinitions = undefined; + this.legacyDefinitionsSource = undefined; + this.legacyProxySource = undefined; + this.detachSubGraphic(); + this.subGraphic.forEach(child => child.release()); + this.subGraphic = []; + } + getNoWorkAnimateAttr(): Record { return Glyph.NOWORK_ANIMATE_ATTR; } diff --git a/packages/vrender-core/src/graphic/graphic.ts b/packages/vrender-core/src/graphic/graphic.ts index 67eb93c13..9fb126967 100644 --- a/packages/vrender-core/src/graphic/graphic.ts +++ b/packages/vrender-core/src/graphic/graphic.ts @@ -2022,7 +2022,38 @@ export abstract class Graphic = Partial, + removedKeys?: readonly string[], + context?: ISetAttributeContext + ): void { + if (!removedKeys?.length) { + graphic.setAttributes(patch, false, context); + return; + } + graphic.detachAttributeFromBaseAttributes(); + const base = graphic.getBaseAttributesStorage() as Record; + removedKeys.forEach(key => delete base[key]); + graphic.applyBaseAttributes(patch); + graphic.commitBaseAttributeMutation(false, context); + } + onAttributeUpdate(context?: ISetAttributeContext) { + if (this.glyphHost) { + Graphic.bindGlyphAttributes(this, this.glyphHost.attribute); + } if (context && context.skipUpdateCallback) { return; } @@ -2440,7 +2471,9 @@ export abstract class Graphic = Partial = Partial void) => void; + /** Observes committed attributes after derived children are synchronized. Honors skipUpdateCallback. */ onUpdate: (cb: (g: this) => void) => void; + + /** Bind after children/context are ready. Runs once immediately and after each host attribute commit. */ + setSubGraphicEncoder: (encoder?: (g: IGlyph, context?: ISetAttributeContext) => void) => void; + + /** Atomically apply derived values and remove owned keys, preserving child state/base truth. */ + commitSubGraphicAttributes: ( + subGraphic: IGraphic, + patch: Record, + removedKeys?: readonly string[], + context?: ISetAttributeContext + ) => void; } From 0965416c1450a60997ed97fc4aa78b46b086f27a Mon Sep 17 00:00:00 2001 From: xile611 Date: Tue, 15 Sep 2026 17:53:49 +0800 Subject: [PATCH 05/18] fix: preserve paint-only invalidation for glyph-derived attributes --- .../unit/graphic/glyph-state.test.ts | 14 ++++++++ .../unit/graphic/glyph-update.test.ts | 11 ++++++ packages/vrender-core/src/graphic/glyph.ts | 25 +------------ packages/vrender-core/src/graphic/graphic.ts | 36 +++++++++++++++++++ 4 files changed, 62 insertions(+), 24 deletions(-) diff --git a/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts b/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts index 3687dbada..cbdd6b827 100644 --- a/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts @@ -1,3 +1,4 @@ +import { StateDefinitionCompiler } from '../../../src/graphic/state/state-definition-compiler'; import { createGlyph } from '../../../src/graphic/glyph'; import { createRect } from '../../../src/graphic/rect'; import { createGroup } from '../../../src/graphic/group'; @@ -213,4 +214,17 @@ describe('Glyph state', () => { glyph.clearStates(false); expect(glyph.registeredActiveScopes).toBeUndefined(); }); + test('repeated legacy state switches reuse compiled definitions', () => { + const { glyph } = createTestGlyph(); + glyph.glyphStateProxy = name => ({ attributes: { fill: name === 'hover' ? 'red' : 'blue' }, subAttributes: [] }); + glyph.useStates(['hover', 'selected'], false); + const compile = jest.spyOn(StateDefinitionCompiler.prototype, 'compile'); + for (let i = 0; i < 20; i++) { + glyph.useStates(['selected', 'hover'], false); + glyph.useStates(['hover', 'selected'], false); + glyph.clearStates(false); + } + expect(compile).not.toHaveBeenCalled(); + compile.mockRestore(); + }); }); diff --git a/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts b/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts index 2fce9b116..4348b9e5e 100644 --- a/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts @@ -99,6 +99,17 @@ describe('Glyph derived attributes', () => { expect((glyph as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).toBe(0); }); + test('derived paint patches do not invalidate child geometry', () => { + const { glyph, child } = createFixture(); + (child as any)._updateTag = 0; + glyph.commitSubGraphicAttributes(child, { fill: 'gray', fillOpacity: 0.5 }); + expect(child.attribute.fill).toBe('gray'); + expect((child as any)._updateTag & UpdateTag.UPDATE_PAINT).not.toBe(0); + expect((child as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).toBe(0); + glyph.commitSubGraphicAttributes(child, { width: 30 }); + expect((child as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).not.toBe(0); + }); + test('clone callbacks are independent and initAttributes resynchronizes children', () => { const { glyph, child, service } = createFixture(); const encode = jest.fn((g, context) => { diff --git a/packages/vrender-core/src/graphic/glyph.ts b/packages/vrender-core/src/graphic/glyph.ts index 14a30554a..e009cc6fb 100644 --- a/packages/vrender-core/src/graphic/glyph.ts +++ b/packages/vrender-core/src/graphic/glyph.ts @@ -140,30 +140,7 @@ export class Glyph extends Graphic implements IGlyph { forceUpdateTag: boolean = false, context?: ISetAttributeContext ): void { - const base = this.getBaseAttributesStorage(); - let category = UpdateCategory.NONE; - let hasKeys = false; - for (const key in params) { - if (!Object.prototype.hasOwnProperty.call(params, key)) { - continue; - } - hasKeys = true; - const prev = (base as any)[key]; - const next = (params as any)[key]; - if (prev !== next) { - category = this.mergeAttributeDeltaCategory(category, key, prev, next); - } - (base as any)[key] = next; - } - if (!hasKeys) { - return; - } - this.attribute = base; - this._baseAttributes = undefined; - this.attributeMayContainTransientAttrs = false; - this.valid = this.isValid(); - this.submitUpdateByCategory(category, forceUpdateTag); - this.onAttributeUpdate(context); + this.commitBaseAttributesByCategory(params, forceUpdateTag, context); } protected commitBaseAttributeBySingleKey( diff --git a/packages/vrender-core/src/graphic/graphic.ts b/packages/vrender-core/src/graphic/graphic.ts index 9fb126967..ff7d86fd2 100644 --- a/packages/vrender-core/src/graphic/graphic.ts +++ b/packages/vrender-core/src/graphic/graphic.ts @@ -978,11 +978,47 @@ export abstract class Graphic = Partial, + forceUpdateTag: boolean = false, + context?: ISetAttributeContext + ): void { + const base = this.getBaseAttributesStorage(); + let category = UpdateCategory.NONE; + let hasKeys = false; + for (const key in params) { + if (!Object.prototype.hasOwnProperty.call(params, key)) { + continue; + } + hasKeys = true; + const prev = (base as any)[key]; + const next = (params as any)[key]; + if (prev !== next) { + category = this.mergeAttributeDeltaCategory(category, key, prev, next); + } + (base as any)[key] = next; + } + if (!hasKeys) { + return; + } + this.attribute = base as T; + this._baseAttributes = undefined; + this.attributeMayContainTransientAttrs = false; + this.valid = this.isValid(); + this.submitUpdateByCategory(category, forceUpdateTag); + this.onAttributeUpdate(context); + } + protected commitBaseAttributesByTouchedKeys( params: Partial, forceUpdateTag: boolean = false, context?: ISetAttributeContext ): void { + if (this.glyphHost) { + this.commitBaseAttributesByCategory(params, forceUpdateTag, context); + return; + } const source = params as Record; const baseAttributes = this.getBaseAttributesStorage() as Record; let hasKeys = false; From de63027fc99b1034693d6d6eadcb9e25a2d56f05 Mon Sep 17 00:00:00 2001 From: xile611 Date: Wed, 16 Sep 2026 17:18:10 +0800 Subject: [PATCH 06/18] fix: invalidate glyph geometry and offset caches --- .../unit/graphic/glyph-update.test.ts | 133 ++++++++++++++++++ .../graphic/state-update-category.test.ts | 18 +++ packages/vrender-core/src/graphic/glyph.ts | 15 +- packages/vrender-core/src/graphic/graphic.ts | 5 + .../state/attribute-update-classifier.ts | 2 + 5 files changed, 172 insertions(+), 1 deletion(-) diff --git a/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts b/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts index 4348b9e5e..cc19ea773 100644 --- a/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts @@ -1,6 +1,10 @@ import { createGlyph } from '../../../src/graphic/glyph'; import { createRect } from '../../../src/graphic/rect'; import { UpdateTag } from '../../../src/common/enums'; +import { application } from '../../../src/application'; +import { DefaultGraphicService } from '../../../src/graphic/graphic-service/graphic-service'; +import { createPath } from '../../../src/graphic/path'; +import { createCircle } from '../../../src/graphic/circle'; const createFixture = () => { const glyph = createGlyph({ fill: 'red', width: 20 }); @@ -136,3 +140,132 @@ describe('Glyph derived attributes', () => { expect((glyph as any).subGraphicEncoder).toBeUndefined(); }); }); + +describe('Glyph cached geometry', () => { + let previousService: typeof application.graphicService; + + beforeEach(() => { + previousService = application.graphicService; + application.graphicService = new DefaultGraphicService(); + }); + + afterEach(() => { + application.graphicService = previousService; + }); + + describe.each(['attributes', 'derived', 'host'])('%s offset updates', writer => { + test.each(['dx', 'dy'])('refreshes cached matrices and bounds for %s', key => { + const glyph = createGlyph({}); + const child = createRect({ width: 10, height: 10 }); + glyph.setSubGraphic([child]); + const readPosition = () => ({ + matrix: key === 'dx' ? child.transMatrix.e : child.transMatrix.f, + child: key === 'dx' ? child.AABBBounds.x1 : child.AABBBounds.y1, + glyph: key === 'dx' ? glyph.AABBBounds.x1 : glyph.AABBBounds.y1 + }); + expect(readPosition()).toEqual({ matrix: 0, child: 0, glyph: 0 }); + + if (writer === 'derived') { + glyph.commitSubGraphicAttributes(child, { [key]: 20 }); + } else if (writer === 'host') { + glyph.setAttributes({ [key]: 20 }); + } else { + child.setAttributes({ [key]: 20 }); + } + + expect(child.attribute[key]).toBe(20); + expect(readPosition()).toEqual({ matrix: 20, child: 20, glyph: 20 }); + }); + }); + + describe.each(['single', 'batch', 'state'])('%s inherited geometry updates', writer => { + [ + { + key: 'path', + initial: 'M0 0H10V10H0Z', + next: 'M0 0H30V10H0Z', + initialWidth: 10, + nextWidth: 30, + createChild: () => createPath({}) + }, + { + key: 'radius', + initial: 10, + next: 30, + initialWidth: 20, + nextWidth: 60, + createChild: () => createCircle({}) + } + ].forEach(({ key, initial, next, initialWidth, nextWidth, createChild }) => { + test(`refreshes child and host geometry for ${key}`, () => { + const glyph = createGlyph({ [key]: initial }); + const child = createChild(); + glyph.setSubGraphic([child]); + // Revalidate after inheritance is bound, including Path's required path attribute. + child.setAttribute('fill', 'red'); + const expectWidths = (width: number) => { + expect(child.AABBBounds.width()).toBe(width); + expect(glyph.AABBBounds.width()).toBe(width); + if ('getParsedPathShape' in child) { + expect(child.getParsedPathShape().getBounds().width()).toBe(width); + } + }; + expectWidths(initialWidth); + + if (writer === 'single') { + glyph.setAttribute(key, next); + } else if (writer === 'batch') { + glyph.setAttributes({ [key]: next }); + } else { + glyph.states = { expanded: { [key]: next } }; + glyph.setStates(['expanded'], false); + } + + expect(child.attribute[key]).toBe(next); + expectWidths(nextWidth); + if (writer === 'state') { + expect(glyph.baseAttributes[key]).toBe(initial); + glyph.clearStates(false); + expect(child.attribute[key]).toBe(initial); + expectWidths(initialWidth); + } + }); + }); + }); + + test.each(['host', 'derived', 'state'])('%s paint updates preserve warmed geometry caches', writer => { + const glyph = createGlyph({ fill: 'red', fillOpacity: 1 }); + const child = createRect({ width: 10, height: 10 }); + glyph.setSubGraphic([child]); + const graphics = [glyph, child]; + const readGeometry = () => + graphics.map(graphic => ({ + width: graphic.AABBBounds.width(), + x: graphic.transMatrix.e, + y: graphic.transMatrix.f, + boundsUpdates: (graphic as any).updateAABBBoundsStamp + })); + const initialGeometry = readGeometry(); + graphics.forEach(graphic => ((graphic as any)._updateTag = UpdateTag.NONE)); + const paint = { fill: 'blue', fillOpacity: 0.5 }; + + if (writer === 'host') { + glyph.setAttributes(paint); + } else if (writer === 'derived') { + glyph.commitSubGraphicAttributes(child, paint); + } else { + glyph.states = { hover: paint }; + glyph.setStates(['hover'], false); + } + + expect(child.attribute.fill).toBe('blue'); + expect(child.attribute.fillOpacity).toBe(0.5); + expect((child as any)._updateTag & UpdateTag.UPDATE_PAINT).not.toBe(0); + graphics.forEach(graphic => { + expect( + (graphic as any)._updateTag & (UpdateTag.UPDATE_SHAPE_AND_BOUNDS | UpdateTag.UPDATE_GLOBAL_LOCAL_MATRIX) + ).toBe(0); + }); + expect(readGeometry()).toEqual(initialGeometry); + }); +}); diff --git a/packages/vrender-core/__tests__/unit/graphic/state-update-category.test.ts b/packages/vrender-core/__tests__/unit/graphic/state-update-category.test.ts index 8950ed0e5..52434be94 100644 --- a/packages/vrender-core/__tests__/unit/graphic/state-update-category.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/state-update-category.test.ts @@ -44,6 +44,24 @@ describe('Graphic state update categories', () => { expect(((graphic as any)._updateTag & UpdateTag.UPDATE_PAINT) === UpdateTag.UPDATE_PAINT).toBe(true); }); + test.each(['dx', 'dy'])('should refresh the cached %s transform on state entry and clear', key => { + const graphic = createGraphic(); + const base = { ...graphic.baseAttributes }; + const readOffset = () => (key === 'dx' ? graphic.transMatrix.e : graphic.transMatrix.f); + expect(readOffset()).toBe(0); + graphic.states = { shifted: { [key]: 20 } }; + + graphic.setStates(['shifted'], false); + expect(graphic.attribute[key]).toBe(20); + expect(readOffset()).toBe(20); + expect((graphic as any)._updateTag & UpdateTag.UPDATE_BOUNDS).not.toBe(0); + expect(graphic.baseAttributes).toEqual(base); + + graphic.clearStates(false); + expect(readOffset()).toBe(0); + expect(graphic.baseAttributes).toEqual(base); + }); + test('should dirty cached global bounds for paint-only updates without upgrading to bounds', () => { const graphic = createGraphic(); const graphicServiceHooks = { diff --git a/packages/vrender-core/src/graphic/glyph.ts b/packages/vrender-core/src/graphic/glyph.ts index e009cc6fb..a945f7536 100644 --- a/packages/vrender-core/src/graphic/glyph.ts +++ b/packages/vrender-core/src/graphic/glyph.ts @@ -12,7 +12,7 @@ import { StateDefinitionCompiler } from './state/state-definition-compiler'; import type { CompiledStateDefinition, StateDefinition, StateDefinitionsInput } from './state/state-definition'; import type { SharedStateScope } from './state/shared-state-scope'; import { getTheme } from './theme'; -import { UpdateCategory } from './state/attribute-update-classifier'; +import { ATTRIBUTE_CATEGORY, UpdateCategory } from './state/attribute-update-classifier'; import { GLYPH_NUMBER_TYPE } from './constants'; export class Glyph extends Graphic implements IGlyph { @@ -180,9 +180,22 @@ export class Glyph extends Graphic implements IGlyph { } protected needUpdateTags(keys: string[]): boolean { + for (const key of keys) { + if (this.needUpdateTag(key)) { + return true; + } + } return false; } protected needUpdateTag(key: string): boolean { + if (ATTRIBUTE_CATEGORY[key] === UpdateCategory.PAINT) { + return false; + } + for (const child of this.subGraphic) { + if (Graphic.needsShapeUpdate(child as Graphic, key)) { + return true; + } + } return false; } diff --git a/packages/vrender-core/src/graphic/graphic.ts b/packages/vrender-core/src/graphic/graphic.ts index ff7d86fd2..53679d98c 100644 --- a/packages/vrender-core/src/graphic/graphic.ts +++ b/packages/vrender-core/src/graphic/graphic.ts @@ -1857,6 +1857,11 @@ export abstract class Graphic = Partial = { shadowColor: UpdateCategory.PAINT, x: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, y: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, + dx: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, + dy: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, scaleX: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, scaleY: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, angle: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, From de54e0a0546cf88aaf4dba1674df6333a867f2b4 Mon Sep 17 00:00:00 2001 From: xile611 Date: Wed, 16 Sep 2026 17:24:48 +0800 Subject: [PATCH 07/18] test: separate source and build artifact checks --- .github/workflows/unit-test.yml | 7 +- common/config/rush/command-line.json | 9 +++ docs/agent/VRENDER_TEST_AND_VERIFICATION.md | 20 ++++- packages/react-vrender-utils/jest.config.js | 5 +- packages/react-vrender/jest.config.js | 9 +-- packages/vrender-animate/jest.config.js | 15 +--- packages/vrender-components/jest.config.js | 22 +---- .../artifacts/container-compatibility.test.ts | 26 ++++++ .../artifacts/root-esm-exports.test.ts | 81 +++++++++++++++++++ .../xml-parser-bundle-safe.test.ts | 0 .../modules/container-compatibility.test.ts | 18 ----- .../unit/public-subpath-exports.test.ts | 68 ---------------- .../vrender-core/jest.artifacts.config.js | 3 + packages/vrender-core/jest.config.js | 15 +--- packages/vrender-core/package.json | 1 + .../build-artifact-imports.test.ts | 14 ++-- .../root-installer-exports.test.ts | 0 .../vrender-kits/jest.artifacts.config.js | 3 + packages/vrender-kits/jest.config.js | 16 +--- packages/vrender-kits/package.json | 1 + .../build-artifact-consistency.test.ts | 0 .../build-artifact-root-imports.test.ts | 40 ++++----- .../unit/source-module-resolution.test.ts | 37 +++++++++ packages/vrender/jest.artifacts.config.js | 3 + packages/vrender/jest.config.js | 20 +---- packages/vrender/package.json | 1 + share/jest-config/create-package-config.js | 14 +++- .../jest-config/source-module-name-mapper.js | 31 +++++++ 28 files changed, 260 insertions(+), 219 deletions(-) create mode 100644 packages/vrender-core/__tests__/artifacts/container-compatibility.test.ts create mode 100644 packages/vrender-core/__tests__/artifacts/root-esm-exports.test.ts rename packages/vrender-core/__tests__/{unit => artifacts}/xml-parser-bundle-safe.test.ts (100%) create mode 100644 packages/vrender-core/jest.artifacts.config.js rename packages/vrender-kits/__tests__/{unit => artifacts}/build-artifact-imports.test.ts (94%) rename packages/vrender-kits/__tests__/{unit => artifacts}/root-installer-exports.test.ts (100%) create mode 100644 packages/vrender-kits/jest.artifacts.config.js rename packages/vrender/__tests__/{unit => artifacts}/build-artifact-consistency.test.ts (100%) rename packages/vrender/__tests__/{unit => artifacts}/build-artifact-root-imports.test.ts (81%) create mode 100644 packages/vrender/__tests__/unit/source-module-resolution.test.ts create mode 100644 packages/vrender/jest.artifacts.config.js create mode 100644 share/jest-config/source-module-name-mapper.js diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 4cecc9e9c..7293ff485 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -41,7 +41,8 @@ jobs: run: node common/scripts/install-run-rush.js update --bypass-policy - name: Install rush run: node common/scripts/install-run-rush.js install --bypass-policy + - name: Source tests (without build artifacts) + run: node common/scripts/install-run-rush.js test --only tag:package - run: node common/scripts/install-run-rush.js build --only tag:package - - name: Pre unit test - run: cd packages/vrender && node ../../common/scripts/install-run-rushx.js test && cd ../.. - - run: node common/scripts/install-run-rush.js test --only tag:package + - name: Published artifact tests + run: node common/scripts/install-run-rush.js test:artifacts --only tag:package diff --git a/common/config/rush/command-line.json b/common/config/rush/command-line.json index a21e413e2..5b81c28fe 100644 --- a/common/config/rush/command-line.json +++ b/common/config/rush/command-line.json @@ -35,6 +35,15 @@ "ignoreDependencyOrder": true, "allowWarningsInSuccessfulBuild": true }, + { + "name": "test:artifacts", + "summary": "Validate published artifacts after building packages", + "enableParallelism": false, + "commandKind": "bulk", + "ignoreMissingScript": true, + "ignoreDependencyOrder": true, + "allowWarningsInSuccessfulBuild": true + }, { "name": "run", "summary": "", diff --git a/docs/agent/VRENDER_TEST_AND_VERIFICATION.md b/docs/agent/VRENDER_TEST_AND_VERIFICATION.md index 3a420dcb0..0682ff115 100644 --- a/docs/agent/VRENDER_TEST_AND_VERIFICATION.md +++ b/docs/agent/VRENDER_TEST_AND_VERIFICATION.md @@ -12,6 +12,7 @@ - `rush compile -t @visactor/vrender-core` - `rush test` - `rush test -t @visactor/vrender` +- `rush test:artifacts --only tag:package`(先 build,再检查发布产物) - `rush eslint` - `rush lint-staged` - `rush start` @@ -41,6 +42,23 @@ ## 单包 Test +`rush test`、各包 `rushx test` 和 pre-push 只运行源码测试:仓库内包通过 +`share/jest-config/source-module-name-mapper.js` 解析到本地 `src`,不依赖 `es/cjs/dist`。 +第三方 npm 依赖仍使用安装版本。CI 在 build 之前运行源码测试,避免旧产物掩盖源码问题。 + +构建产物断言位于各包 `__tests__/artifacts/`,默认单测和 Electron 测试不会收集它们。 +`vrender-core`、`vrender-kits`、`vrender` 提供独立的 `rushx test:artifacts`;这些测试不启用源码映射, +并在缺少构建文件时失败。跨包产物检查需要先构建全部 package: + +```bash +rush build --only tag:package +rush test:artifacts --only tag:package +``` + +新增测试时,运行时行为及源码/配置契约归入默认单测;读取构建输出或验证发布包入口的断言归入 artifacts。 +同时包含两类断言的文件应拆分。验证源码测试独立性时,在没有各包 `es/cjs/dist` 的干净 worktree 中执行 +`rush test --only tag:package`,不通过跳过失败用例或自动 build 消除依赖。 + 在对应 package 下: - `cd packages/vrender-core && rushx test` @@ -135,7 +153,7 @@ root: - `packages/vrender/__tests__/unit/shared-browser-lite-entry.test.ts` - `packages/vrender/__tests__/unit/app-bootstrap-binding.test.ts` - `packages/vrender/__tests__/unit/node-app-runtime.test.ts` -- `packages/vrender/__tests__/unit/build-artifact-consistency.test.ts` +- 发布产物检查:`packages/vrender/__tests__/artifacts/*` ## Animate 测试 diff --git a/packages/react-vrender-utils/jest.config.js b/packages/react-vrender-utils/jest.config.js index 7723b5d3a..c3519ed9c 100644 --- a/packages/react-vrender-utils/jest.config.js +++ b/packages/react-vrender-utils/jest.config.js @@ -1,4 +1,3 @@ -const path = require('path'); const { createStablePackageJestConfig } = require('../../share/jest-config/create-package-config'); module.exports = createStablePackageJestConfig({ @@ -22,7 +21,5 @@ module.exports = createStablePackageJestConfig({ '!**/interface.ts', '!**/**.d.ts' ], - moduleNameMapper: { - '@visactor/react-vrender': path.resolve(__dirname, '../react-vrender/src/index.ts') - } + moduleNameMapper: require('../../share/jest-config/source-module-name-mapper') }); diff --git a/packages/react-vrender/jest.config.js b/packages/react-vrender/jest.config.js index f7cea21c7..6f6e565cf 100644 --- a/packages/react-vrender/jest.config.js +++ b/packages/react-vrender/jest.config.js @@ -1,4 +1,3 @@ -const path = require('path'); const { createStablePackageJestConfig } = require('../../share/jest-config/create-package-config'); module.exports = createStablePackageJestConfig({ @@ -22,11 +21,5 @@ module.exports = createStablePackageJestConfig({ '!**/interface.ts', '!**/**.d.ts' ], - moduleNameMapper: { - '@visactor/vrender': path.resolve(__dirname, '../vrender/src/index.ts'), - '@visactor/vrender-core': path.resolve(__dirname, '../vrender-core/src/index.ts'), - '@visactor/vrender-kits': path.resolve(__dirname, '../vrender-kits/src/index.ts'), - '@visactor/vrender-animate': path.resolve(__dirname, '../vrender-animate/src/index.ts'), - '@visactor/vrender-components': path.resolve(__dirname, '../vrender-components/src/index.ts') - } + moduleNameMapper: require('../../share/jest-config/source-module-name-mapper') }); diff --git a/packages/vrender-animate/jest.config.js b/packages/vrender-animate/jest.config.js index 8f3c7183f..2a0aaf36e 100644 --- a/packages/vrender-animate/jest.config.js +++ b/packages/vrender-animate/jest.config.js @@ -1,4 +1,3 @@ -const path = require('path'); const { createStablePackageJestConfig } = require('../../share/jest-config/create-package-config'); module.exports = createStablePackageJestConfig({ @@ -21,17 +20,5 @@ module.exports = createStablePackageJestConfig({ '!**/interface.ts', '!**/**.d.ts' ], - moduleNameMapper: { - '^@visactor/vrender-core/event/constant$': path.resolve(__dirname, '../vrender-core/src/event/public-constant.ts'), - '^@visactor/vrender-core/render/draw-interceptor$': path.resolve( - __dirname, - '../vrender-core/src/render/contributions/render/draw-interceptor.ts' - ), - '^@visactor/vrender-core/render/symbol$': path.resolve( - __dirname, - '../vrender-core/src/render/contributions/render/symbol.ts' - ), - '^@visactor/vrender-core/(.*)$': path.resolve(__dirname, '../vrender-core/src/$1'), - '^@visactor/vrender-core$': path.resolve(__dirname, '../vrender-core/src/index.ts') - } + moduleNameMapper: require('../../share/jest-config/source-module-name-mapper') }); diff --git a/packages/vrender-components/jest.config.js b/packages/vrender-components/jest.config.js index 77d273fc3..f771948d3 100644 --- a/packages/vrender-components/jest.config.js +++ b/packages/vrender-components/jest.config.js @@ -1,4 +1,3 @@ -const path = require('path'); const { createStablePackageJestConfig } = require('../../share/jest-config/create-package-config'); module.exports = createStablePackageJestConfig({ @@ -10,24 +9,5 @@ module.exports = createStablePackageJestConfig({ tsconfig: './tsconfig.test.json', collectCoverageFrom: ['src/**/*.ts', '!**/type/**'], coveragePathIgnorePatterns: ['node_modules', '__tests__', 'interface.ts', '.d.ts', 'typings', 'type.ts'], - moduleNameMapper: { - '^@visactor/vrender-kits/(.*)$': path.resolve(__dirname, '../vrender-kits/src/$1'), - '^@visactor/vrender-kits$': path.resolve(__dirname, '../vrender-kits/src/index.ts'), - '^@visactor/vrender-core/event/constant$': path.resolve(__dirname, '../vrender-core/src/event/public-constant.ts'), - '^@visactor/vrender-core/render/draw-interceptor$': path.resolve( - __dirname, - '../vrender-core/src/render/contributions/render/draw-interceptor.ts' - ), - '^@visactor/vrender-core/render/symbol$': path.resolve( - __dirname, - '../vrender-core/src/render/contributions/render/symbol.ts' - ), - '^@visactor/vrender-core/(.*)$': path.resolve(__dirname, '../vrender-core/src/$1'), - '^@visactor/vrender-core$': path.resolve(__dirname, '../vrender-core/src/index.ts'), - '^@visactor/vrender/es/core$': path.resolve(__dirname, '../vrender/src/index.ts'), - '^@visactor/vrender/es/register$': path.resolve(__dirname, '../vrender/src/register.ts'), - '^@visactor/vrender/es/kits$': path.resolve(__dirname, '../vrender/src/kits.ts'), - '^@visactor/vrender-animate/(.*)$': path.resolve(__dirname, '../vrender-animate/src/$1'), - '^@visactor/vrender-animate$': path.resolve(__dirname, '../vrender-animate/src/index.ts') - } + moduleNameMapper: require('../../share/jest-config/source-module-name-mapper') }); diff --git a/packages/vrender-core/__tests__/artifacts/container-compatibility.test.ts b/packages/vrender-core/__tests__/artifacts/container-compatibility.test.ts new file mode 100644 index 000000000..0a81e3d1c --- /dev/null +++ b/packages/vrender-core/__tests__/artifacts/container-compatibility.test.ts @@ -0,0 +1,26 @@ +declare const require: any; +export {}; + +const fs = require('fs'); +const path = require('path'); +const process = require('process'); +const packageRoot = process.cwd(); + +function readArtifact(relativePath: string) { + return fs.readFileSync(path.join(packageRoot, relativePath), 'utf8'); +} + +describe('vrender-core published container compatibility', () => { + test('es artifacts should expose legacy container compatibility surface', () => { + expect(readArtifact('es/modules.js')).toContain('export const container'); + expect(readArtifact('es/modules.d.ts')).toContain('container'); + expect(readArtifact('es/index.d.ts')).toContain("from './modules'"); + expect(readArtifact('es/index.d.ts')).toContain('container'); + }); + + test('cjs root export should expose container as the legacy binding context', () => { + const vrenderCore = require(path.join(packageRoot, 'cjs/index.js')); + + expect(vrenderCore.container).toBe(vrenderCore.getLegacyBindingContext()); + }); +}); diff --git a/packages/vrender-core/__tests__/artifacts/root-esm-exports.test.ts b/packages/vrender-core/__tests__/artifacts/root-esm-exports.test.ts new file mode 100644 index 000000000..a15422675 --- /dev/null +++ b/packages/vrender-core/__tests__/artifacts/root-esm-exports.test.ts @@ -0,0 +1,81 @@ +/** + * @jest-environment node + */ + +declare const __dirname: string; +declare const require: any; +export {}; + +const fs = require('fs'); +const path = require('path'); +const packageRoot = path.resolve(__dirname, '../..'); + +describe('vrender-core published root exports', () => { + test('keeps BytePack-sensitive root runtime exports explicit in the ESM artifact', () => { + const artifact = fs.readFileSync(path.join(packageRoot, 'es/index.js'), 'utf8'); + const explicitExports = new Set(); + const exportPattern = /export\s+\{([^}]+)\}/g; + let match: RegExpExecArray | null; + + while ((match = exportPattern.exec(artifact))) { + match[1] + .split(',') + .map((item: string) => item.trim()) + .filter(Boolean) + .forEach((specifier: string) => { + const exportedName = specifier.match(/\s+as\s+([A-Za-z0-9_$]+)$/)?.[1] ?? specifier; + explicitExports.add(exportedName.trim()); + }); + } + + const expectedExplicitRuntimeExports = [ + 'CustomEvent', + 'CustomPath2D', + 'GradientParser', + 'IContainPointMode', + 'Symbol', + 'builtInSymbolStrMap', + 'builtinSymbols', + 'builtinSymbolsMap', + 'container', + 'createArc', + 'createArc3d', + 'createArea', + 'createGlyph', + 'createGroup', + 'createImage', + 'createLine', + 'createPath', + 'createPolygon', + 'createPyramid3d', + 'createRect', + 'createRect3d', + 'createRichText', + 'createSymbol', + 'createText', + 'getRichTextBounds', + 'getTextBounds', + 'graphicCreator', + 'isBrowserEnv', + 'mapToCanvasPointForCanvas', + 'matrixAllocate', + 'registerDirectionalLight', + 'registerGlobalEventTransformer', + 'registerHtmlAttributePlugin', + 'registerOrthoCamera', + 'registerReactAttributePlugin', + 'registerViewTransform3dPlugin', + 'registerWindowEventTransformer', + 'transformPointForCanvas', + 'vglobal', + 'waitForAllSubLayers' + ]; + + expect(expectedExplicitRuntimeExports.filter(name => !explicitExports.has(name))).toEqual([]); + + expect(artifact).toContain('export { Symbol, createSymbol } from "./graphic/symbol"'); + expect(artifact).toContain( + 'export { builtInSymbolStrMap, builtinSymbols, builtinSymbolsMap } from "./graphic/builtin-symbol"' + ); + }); +}); diff --git a/packages/vrender-core/__tests__/unit/xml-parser-bundle-safe.test.ts b/packages/vrender-core/__tests__/artifacts/xml-parser-bundle-safe.test.ts similarity index 100% rename from packages/vrender-core/__tests__/unit/xml-parser-bundle-safe.test.ts rename to packages/vrender-core/__tests__/artifacts/xml-parser-bundle-safe.test.ts diff --git a/packages/vrender-core/__tests__/unit/modules/container-compatibility.test.ts b/packages/vrender-core/__tests__/unit/modules/container-compatibility.test.ts index 84364003c..16c5fa832 100644 --- a/packages/vrender-core/__tests__/unit/modules/container-compatibility.test.ts +++ b/packages/vrender-core/__tests__/unit/modules/container-compatibility.test.ts @@ -1,16 +1,11 @@ declare const require: any; export {}; -const fs = require('fs'); const path = require('path'); const process = require('process'); const packageRoot = process.cwd(); -function readArtifact(relativePath: string) { - return fs.readFileSync(path.join(packageRoot, relativePath), 'utf8'); -} - describe('vrender-core container compatibility', () => { test('application should use realm-level shared state for duplicated ESM entry evaluation', () => { const { application } = require(path.join(packageRoot, 'src/application')); @@ -19,17 +14,4 @@ describe('vrender-core container compatibility', () => { expect(state).toBeDefined(); expect(state.application).toBe(application); }); - - test('es artifacts should expose legacy container compatibility surface', () => { - expect(readArtifact('es/modules.js')).toContain('export const container'); - expect(readArtifact('es/modules.d.ts')).toContain('container'); - expect(readArtifact('es/index.d.ts')).toContain("from './modules'"); - expect(readArtifact('es/index.d.ts')).toContain('container'); - }); - - test('cjs root export should expose container as the legacy binding context', () => { - const vrenderCore = require(path.join(packageRoot, 'cjs/index.js')); - - expect(vrenderCore.container).toBe(vrenderCore.getLegacyBindingContext()); - }); }); diff --git a/packages/vrender-core/__tests__/unit/public-subpath-exports.test.ts b/packages/vrender-core/__tests__/unit/public-subpath-exports.test.ts index e9e698d6c..96cf86347 100644 --- a/packages/vrender-core/__tests__/unit/public-subpath-exports.test.ts +++ b/packages/vrender-core/__tests__/unit/public-subpath-exports.test.ts @@ -77,72 +77,4 @@ describe('vrender-core public subpath exports', () => { expect(packageJson.typesVersions?.['*']).toEqual(expectedTypesVersions); }); - - test('keeps BytePack-sensitive root runtime exports explicit in the ESM artifact', () => { - const artifact = fs.readFileSync(path.join(packageRoot, 'es/index.js'), 'utf8'); - const explicitExports = new Set(); - const exportPattern = /export\s+\{([^}]+)\}/g; - let match: RegExpExecArray | null; - - while ((match = exportPattern.exec(artifact))) { - match[1] - .split(',') - .map((item: string) => item.trim()) - .filter(Boolean) - .forEach((specifier: string) => { - const exportedName = specifier.match(/\s+as\s+([A-Za-z0-9_$]+)$/)?.[1] ?? specifier; - explicitExports.add(exportedName.trim()); - }); - } - - const expectedExplicitRuntimeExports = [ - 'CustomEvent', - 'CustomPath2D', - 'GradientParser', - 'IContainPointMode', - 'Symbol', - 'builtInSymbolStrMap', - 'builtinSymbols', - 'builtinSymbolsMap', - 'container', - 'createArc', - 'createArc3d', - 'createArea', - 'createGlyph', - 'createGroup', - 'createImage', - 'createLine', - 'createPath', - 'createPolygon', - 'createPyramid3d', - 'createRect', - 'createRect3d', - 'createRichText', - 'createSymbol', - 'createText', - 'getRichTextBounds', - 'getTextBounds', - 'graphicCreator', - 'isBrowserEnv', - 'mapToCanvasPointForCanvas', - 'matrixAllocate', - 'registerDirectionalLight', - 'registerGlobalEventTransformer', - 'registerHtmlAttributePlugin', - 'registerOrthoCamera', - 'registerReactAttributePlugin', - 'registerViewTransform3dPlugin', - 'registerWindowEventTransformer', - 'transformPointForCanvas', - 'vglobal', - 'waitForAllSubLayers' - ]; - - expect(expectedExplicitRuntimeExports.filter(name => !explicitExports.has(name))).toEqual([]); - - expect(artifact).toContain('export { Symbol, createSymbol } from "./graphic/symbol"'); - expect(artifact).toContain( - 'export { builtInSymbolStrMap, builtinSymbols, builtinSymbolsMap } from "./graphic/builtin-symbol"' - ); - }); }); diff --git a/packages/vrender-core/jest.artifacts.config.js b/packages/vrender-core/jest.artifacts.config.js new file mode 100644 index 000000000..5b0698ca3 --- /dev/null +++ b/packages/vrender-core/jest.artifacts.config.js @@ -0,0 +1,3 @@ +const { createArtifactPackageJestConfig } = require('../../share/jest-config/create-package-config'); + +module.exports = createArtifactPackageJestConfig(); diff --git a/packages/vrender-core/jest.config.js b/packages/vrender-core/jest.config.js index 216f18390..ce06cacae 100644 --- a/packages/vrender-core/jest.config.js +++ b/packages/vrender-core/jest.config.js @@ -1,4 +1,3 @@ -const path = require('path'); const { createStablePackageJestConfig } = require('../../share/jest-config/create-package-config'); module.exports = createStablePackageJestConfig({ @@ -29,17 +28,5 @@ module.exports = createStablePackageJestConfig({ statements: 80 } }, - moduleNameMapper: { - '^@visactor/vrender-core/event/constant$': path.resolve(__dirname, './src/event/public-constant.ts'), - '^@visactor/vrender-core/render/draw-interceptor$': path.resolve( - __dirname, - './src/render/contributions/render/draw-interceptor.ts' - ), - '^@visactor/vrender-core/render/symbol$': path.resolve( - __dirname, - './src/render/contributions/render/symbol.ts' - ), - '^@visactor/vrender-core/(.*)$': path.resolve(__dirname, './src/$1'), - '^@visactor/vrender-core$': path.resolve(__dirname, './src/index.ts') - } + moduleNameMapper: require('../../share/jest-config/source-module-name-mapper') }); diff --git a/packages/vrender-core/package.json b/packages/vrender-core/package.json index ebba386fe..f133c6e51 100644 --- a/packages/vrender-core/package.json +++ b/packages/vrender-core/package.json @@ -59,6 +59,7 @@ "dev": "cross-env DEBUG='Bundler*' bundle --clean -f es -w", "start": "vite ./__tests__/browser", "test": "jest -c jest.config.js", + "test:artifacts": "jest -c jest.artifacts.config.js", "test:electron": "jest -c jest.electron.config.js --runInBand --passWithNoTests --testPathPattern='__tests__/(browser|electron)/'", "test-live": "npm run test-watch __tests__/unit/theme/line.test.ts", "test-watch": "cross-env DEBUG_MODE=1 jest --watch -c jest.config.js", diff --git a/packages/vrender-kits/__tests__/unit/build-artifact-imports.test.ts b/packages/vrender-kits/__tests__/artifacts/build-artifact-imports.test.ts similarity index 94% rename from packages/vrender-kits/__tests__/unit/build-artifact-imports.test.ts rename to packages/vrender-kits/__tests__/artifacts/build-artifact-imports.test.ts index 1b44b0ceb..6f8354ac0 100644 --- a/packages/vrender-kits/__tests__/unit/build-artifact-imports.test.ts +++ b/packages/vrender-kits/__tests__/artifacts/build-artifact-imports.test.ts @@ -51,10 +51,6 @@ const forbiddenCoreRootRuntimeImports = [ function collectArtifactFiles(relativeDir: string): string[] { const absoluteDir = path.join(packageRoot, relativeDir); - if (!fs.existsSync(absoluteDir)) { - return []; - } - const entries = fs.readdirSync(absoluteDir, { withFileTypes: true }); return entries.flatMap((entry: { isDirectory: () => boolean; name: string }) => { const relativePath = path.join(relativeDir, entry.name); @@ -69,12 +65,14 @@ function collectArtifactFiles(relativeDir: string): string[] { describe('vrender-kits published artifacts', () => { test('should not reference workspace source directories', () => { - const offenders = buildRoots.flatMap(buildRoot => - collectArtifactFiles(buildRoot).flatMap(relativePath => { + const offenders = buildRoots.flatMap(buildRoot => { + const artifactFiles = collectArtifactFiles(buildRoot); + expect(artifactFiles.length).toBeGreaterThan(0); + return artifactFiles.flatMap(relativePath => { const artifact = fs.readFileSync(path.join(packageRoot, relativePath), 'utf8'); return forbiddenWorkspaceSourcePatterns.some(pattern => pattern.test(artifact)) ? [relativePath] : []; - }) - ); + }); + }); expect(offenders).toEqual([]); }); diff --git a/packages/vrender-kits/__tests__/unit/root-installer-exports.test.ts b/packages/vrender-kits/__tests__/artifacts/root-installer-exports.test.ts similarity index 100% rename from packages/vrender-kits/__tests__/unit/root-installer-exports.test.ts rename to packages/vrender-kits/__tests__/artifacts/root-installer-exports.test.ts diff --git a/packages/vrender-kits/jest.artifacts.config.js b/packages/vrender-kits/jest.artifacts.config.js new file mode 100644 index 000000000..5b0698ca3 --- /dev/null +++ b/packages/vrender-kits/jest.artifacts.config.js @@ -0,0 +1,3 @@ +const { createArtifactPackageJestConfig } = require('../../share/jest-config/create-package-config'); + +module.exports = createArtifactPackageJestConfig(); diff --git a/packages/vrender-kits/jest.config.js b/packages/vrender-kits/jest.config.js index 261684d3b..2a0aaf36e 100644 --- a/packages/vrender-kits/jest.config.js +++ b/packages/vrender-kits/jest.config.js @@ -1,4 +1,3 @@ -const path = require('path'); const { createStablePackageJestConfig } = require('../../share/jest-config/create-package-config'); module.exports = createStablePackageJestConfig({ @@ -21,18 +20,5 @@ module.exports = createStablePackageJestConfig({ '!**/interface.ts', '!**/**.d.ts' ], - moduleNameMapper: { - '^@visactor/vrender-core/event/constant$': path.resolve(__dirname, '../vrender-core/src/event/public-constant.ts'), - '^@visactor/vrender-core/render/draw-interceptor$': path.resolve( - __dirname, - '../vrender-core/src/render/contributions/render/draw-interceptor.ts' - ), - '^@visactor/vrender-core/render/symbol$': path.resolve( - __dirname, - '../vrender-core/src/render/contributions/render/symbol.ts' - ), - '^@visactor/vrender-core/(.*)$': path.resolve(__dirname, '../vrender-core/src/$1'), - '^@visactor/vrender-core$': path.resolve(__dirname, '../vrender-core/src/index.ts'), - '@visactor/vrender-animate': path.resolve(__dirname, '../vrender-animate/src/index.ts') - } + moduleNameMapper: require('../../share/jest-config/source-module-name-mapper') }); diff --git a/packages/vrender-kits/package.json b/packages/vrender-kits/package.json index 555c288c2..5f5342587 100644 --- a/packages/vrender-kits/package.json +++ b/packages/vrender-kits/package.json @@ -31,6 +31,7 @@ "dev": "cross-env DEBUG='Bundler*' bundle --clean -f es -w", "start": "vite ./vite", "test": "jest -c jest.config.js", + "test:artifacts": "jest -c jest.artifacts.config.js", "test:electron": "jest -c jest.electron.config.js --runInBand --passWithNoTests --testPathPattern='__tests__/(browser|electron)/'", "test-cov": "jest -c jest.config.js --coverage", "test-watch": "cross-env DEBUG_MODE=1 jest --watch -c jest.config.js" diff --git a/packages/vrender/__tests__/unit/build-artifact-consistency.test.ts b/packages/vrender/__tests__/artifacts/build-artifact-consistency.test.ts similarity index 100% rename from packages/vrender/__tests__/unit/build-artifact-consistency.test.ts rename to packages/vrender/__tests__/artifacts/build-artifact-consistency.test.ts diff --git a/packages/vrender/__tests__/unit/build-artifact-root-imports.test.ts b/packages/vrender/__tests__/artifacts/build-artifact-root-imports.test.ts similarity index 81% rename from packages/vrender/__tests__/unit/build-artifact-root-imports.test.ts rename to packages/vrender/__tests__/artifacts/build-artifact-root-imports.test.ts index a4e6e3396..bf5f49a72 100644 --- a/packages/vrender/__tests__/unit/build-artifact-root-imports.test.ts +++ b/packages/vrender/__tests__/artifacts/build-artifact-root-imports.test.ts @@ -28,10 +28,6 @@ function collectPackageDirs(): string[] { function collectArtifactFiles(packageRoot: string, relativeDir: string): string[] { const absoluteDir = path.join(packageRoot, relativeDir); - if (!fs.existsSync(absoluteDir)) { - return []; - } - return fs .readdirSync(absoluteDir, { withFileTypes: true }) .flatMap((entry: { isDirectory: () => boolean; name: string }) => { @@ -85,27 +81,21 @@ function collectRootNamedImports(artifact: string, packageName: string): string[ describe('published root bundle imports', () => { test('root named imports between VRender package artifacts should exist in target root bundle exports', () => { - const packages = collectPackageDirs() - .map((dir: string) => { - const packageRoot = path.join(packagesRoot, dir); - const bundlePath = path.join(packageRoot, rootBundlePath); - - if (!fs.existsSync(bundlePath)) { - return null; - } - - const packageJson = JSON.parse(readText(path.join(packageRoot, 'package.json'))); - const artifact = readText(bundlePath); - - return { - name: packageJson.name, - dir, - packageRoot, - artifactFiles: scannedBuildRoots.flatMap(relativeDir => collectArtifactFiles(packageRoot, relativeDir)), - exports: new Set(collectBundleNamedExports(artifact)) - }; - }) - .filter(Boolean); + const packages = collectPackageDirs().map((dir: string) => { + const packageRoot = path.join(packagesRoot, dir); + const bundlePath = path.join(packageRoot, rootBundlePath); + + const packageJson = JSON.parse(readText(path.join(packageRoot, 'package.json'))); + const artifact = readText(bundlePath); + + return { + name: packageJson.name, + dir, + packageRoot, + artifactFiles: scannedBuildRoots.flatMap(relativeDir => collectArtifactFiles(packageRoot, relativeDir)), + exports: new Set(collectBundleNamedExports(artifact)) + }; + }); const failures: string[] = []; diff --git a/packages/vrender/__tests__/unit/source-module-resolution.test.ts b/packages/vrender/__tests__/unit/source-module-resolution.test.ts new file mode 100644 index 000000000..30b8d524c --- /dev/null +++ b/packages/vrender/__tests__/unit/source-module-resolution.test.ts @@ -0,0 +1,37 @@ +/** + * @jest-environment node + */ + +declare const require: any; +declare const __dirname: string; +export {}; + +const path = require('path'); +const packagesRoot = path.resolve(__dirname, '../../..'); + +describe('workspace source module resolution', () => { + test.each([ + 'vrender', + 'vrender-core', + 'vrender-kits', + 'vrender-animate', + 'vrender-components', + 'react-vrender', + 'react-vrender-utils' + ])('%s resolves to its own source entry without a build', packageName => { + expect(require.resolve(`@visactor/${packageName}`)).toBe(path.join(packagesRoot, packageName, 'src/index.ts')); + }); + + test.each([ + ['vrender/entries/node', 'vrender/src/entries/node.ts'], + ['vrender-core/event/constant', 'vrender-core/src/event/public-constant.ts'], + ['vrender-core/render/draw-interceptor', 'vrender-core/src/render/contributions/render/draw-interceptor.ts'], + ['vrender-core/render/symbol', 'vrender-core/src/render/contributions/render/symbol.ts'], + ['vrender-kits/register/register-line', 'vrender-kits/src/register/register-line.ts'], + ['vrender-animate/register', 'vrender-animate/src/register.ts'], + ['vrender-components/brush', 'vrender-components/src/brush/index.ts'], + ['react-vrender/processProps', 'react-vrender/src/processProps.ts'] + ])('%s resolves to its source subpath', (specifier, source) => { + expect(require.resolve(`@visactor/${specifier}`)).toBe(path.join(packagesRoot, source)); + }); +}); diff --git a/packages/vrender/jest.artifacts.config.js b/packages/vrender/jest.artifacts.config.js new file mode 100644 index 000000000..5b0698ca3 --- /dev/null +++ b/packages/vrender/jest.artifacts.config.js @@ -0,0 +1,3 @@ +const { createArtifactPackageJestConfig } = require('../../share/jest-config/create-package-config'); + +module.exports = createArtifactPackageJestConfig(); diff --git a/packages/vrender/jest.config.js b/packages/vrender/jest.config.js index 8ea2959d0..ce06cacae 100644 --- a/packages/vrender/jest.config.js +++ b/packages/vrender/jest.config.js @@ -1,4 +1,3 @@ -const path = require('path'); const { createStablePackageJestConfig } = require('../../share/jest-config/create-package-config'); module.exports = createStablePackageJestConfig({ @@ -29,22 +28,5 @@ module.exports = createStablePackageJestConfig({ statements: 80 } }, - moduleNameMapper: { - '^@visactor/vrender-kits/(.*)$': path.resolve(__dirname, '../vrender-kits/src/$1'), - '^@visactor/vrender-kits$': path.resolve(__dirname, '../vrender-kits/src/index.ts'), - '^@visactor/vrender-animate/(.*)$': path.resolve(__dirname, '../vrender-animate/src/$1'), - '^@visactor/vrender-core/event/constant$': path.resolve(__dirname, '../vrender-core/src/event/public-constant.ts'), - '^@visactor/vrender-core/render/draw-interceptor$': path.resolve( - __dirname, - '../vrender-core/src/render/contributions/render/draw-interceptor.ts' - ), - '^@visactor/vrender-core/render/symbol$': path.resolve( - __dirname, - '../vrender-core/src/render/contributions/render/symbol.ts' - ), - '^@visactor/vrender-core/(.*)$': path.resolve(__dirname, '../vrender-core/src/$1'), - '^@visactor/vrender-core$': path.resolve(__dirname, '../vrender-core/src/index.ts'), - '^@visactor/vrender-animate$': path.resolve(__dirname, '../vrender-animate/src/index.ts'), - '^@visactor/vrender-components$': path.resolve(__dirname, '../vrender-components/src/index.ts') - } + moduleNameMapper: require('../../share/jest-config/source-module-name-mapper') }); diff --git a/packages/vrender/package.json b/packages/vrender/package.json index c5f6ce1b7..aedd1c87c 100644 --- a/packages/vrender/package.json +++ b/packages/vrender/package.json @@ -25,6 +25,7 @@ "dev": "cross-env DEBUG='Bundler*' bundle --clean -f es -w", "start": "vite ./__tests__/browser --host", "test": "jest -c jest.config.js", + "test:artifacts": "jest -c jest.artifacts.config.js", "test:electron": "jest -c jest.electron.config.js --runInBand --passWithNoTests --testPathPattern='__tests__/(browser|electron)/'", "test-cov": "jest -c jest.config.js -w 16 --coverage", "test-live": "npm run test-watch __tests__/unit/theme/line.test.ts", diff --git a/share/jest-config/create-package-config.js b/share/jest-config/create-package-config.js index 57af303a1..76b2dfca3 100644 --- a/share/jest-config/create-package-config.js +++ b/share/jest-config/create-package-config.js @@ -62,7 +62,7 @@ function createStablePackageJestConfig(options = {}) { verbose, coverageReporters, coveragePathIgnorePatterns, - testPathIgnorePatterns, + testPathIgnorePatterns: ['/node_modules/', '/__tests__/artifacts/', ...testPathIgnorePatterns], collectCoverageFrom }; @@ -77,6 +77,17 @@ function createStablePackageJestConfig(options = {}) { return config; } +function createArtifactPackageJestConfig() { + return { + ...createStablePackageJestConfig({ + environment: 'node', + testRegex: '/__tests__/artifacts/.*\\.test\\.ts$' + }), + // Published package imports must resolve normally, without source aliases. + testPathIgnorePatterns: ['/node_modules/'] + }; +} + function createElectronPackageJestConfig(options = {}) { const { rootDir, ...rest } = options; @@ -91,5 +102,6 @@ function createElectronPackageJestConfig(options = {}) { module.exports = { createStablePackageJestConfig, + createArtifactPackageJestConfig, createElectronPackageJestConfig }; diff --git a/share/jest-config/source-module-name-mapper.js b/share/jest-config/source-module-name-mapper.js new file mode 100644 index 000000000..be5700ce5 --- /dev/null +++ b/share/jest-config/source-module-name-mapper.js @@ -0,0 +1,31 @@ +const path = require('path'); + +const packagesRoot = path.resolve(__dirname, '../../packages'); + +// Public subpaths whose source filenames differ from their import paths. +const mapper = { + '^@visactor/vrender-core/event/constant$': path.join(packagesRoot, 'vrender-core/src/event/public-constant.ts'), + '^@visactor/vrender-core/render/draw-interceptor$': path.join( + packagesRoot, + 'vrender-core/src/render/contributions/render/draw-interceptor.ts' + ), + '^@visactor/vrender-core/render/symbol$': path.join( + packagesRoot, + 'vrender-core/src/render/contributions/render/symbol.ts' + ) +}; + +for (const packageName of [ + 'vrender', + 'vrender-core', + 'vrender-kits', + 'vrender-animate', + 'vrender-components', + 'react-vrender', + 'react-vrender-utils' +]) { + mapper[`^@visactor/${packageName}$`] = path.join(packagesRoot, packageName, 'src/index.ts'); + mapper[`^@visactor/${packageName}/(.*)$`] = path.join(packagesRoot, packageName, 'src/$1'); +} + +module.exports = mapper; From 770a9fca5ff13844fdc67ce8275ec5e8b4ce32a1 Mon Sep 17 00:00:00 2001 From: kkxxkk2019 Date: Thu, 20 Aug 2026 16:28:54 +0800 Subject: [PATCH 08/18] fix(geometry): ignore invalid points in bounds (cherry picked from commit b568bb44ce006f821decf514d1187fd18bb21111) --- .../graphic/invalid-defined-bounds.test.ts | 54 +++++++++++++++++++ packages/vrender-core/src/graphic/area.ts | 6 +++ packages/vrender-core/src/graphic/line.ts | 8 +-- 3 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 packages/vrender-core/__tests__/graphic/invalid-defined-bounds.test.ts diff --git a/packages/vrender-core/__tests__/graphic/invalid-defined-bounds.test.ts b/packages/vrender-core/__tests__/graphic/invalid-defined-bounds.test.ts new file mode 100644 index 000000000..40e451d47 --- /dev/null +++ b/packages/vrender-core/__tests__/graphic/invalid-defined-bounds.test.ts @@ -0,0 +1,54 @@ +import { AABBBounds } from '@visactor/vutils'; +import { Area } from '../../src/graphic/area'; +import { Line } from '../../src/graphic/line'; + +function expectBounds(bounds: AABBBounds) { + expect(bounds.x1).toBe(0); + expect(bounds.y1).toBe(0); + expect(bounds.x2).toBe(10); + expect(bounds.y2).toBe(10); +} + +describe('invalid defined points', () => { + test('line bounds exclude invalid points when connecting the remaining points', () => { + const points = [ + { x: 0, y: 0 }, + { x: 500, y: 500, defined: false }, + { x: 10, y: 10 } + ]; + const line = new Line({ points, connectedType: 'connect' }); + + const pointBounds = new AABBBounds(); + (line as any).updateLineAABBBoundsByPoints(line.attribute, { points }, pointBounds); + expectBounds(pointBounds); + + const segmentBounds = new AABBBounds(); + (line as any).updateLineAABBBoundsBySegments( + { segments: [{ points }], connectedType: 'connect' }, + { segments: [{ points }] }, + segmentBounds + ); + expectBounds(segmentBounds); + }); + + test('area bounds exclude both coordinates of invalid points', () => { + const points = [ + { x: 0, y: 0, y1: 2 }, + { x: 500, y: 500, y1: -500, defined: false }, + { x: 10, y: 10, y1: 4 } + ]; + const area = new Area({ points, connectedType: 'connect' }); + + const pointBounds = new AABBBounds(); + (area as any).updateAreaAABBBoundsByPoints(area.attribute, { points }, pointBounds); + expectBounds(pointBounds); + + const segmentBounds = new AABBBounds(); + (area as any).updateAreaAABBBoundsBySegments( + { segments: [{ points }], connectedType: 'connect' }, + { segments: [{ points }] }, + segmentBounds + ); + expectBounds(segmentBounds); + }); +}); diff --git a/packages/vrender-core/src/graphic/area.ts b/packages/vrender-core/src/graphic/area.ts index 391da50be..74579ad78 100644 --- a/packages/vrender-core/src/graphic/area.ts +++ b/packages/vrender-core/src/graphic/area.ts @@ -88,6 +88,9 @@ export class Area extends Graphic implements IArea { const { points = areaTheme.points } = attribute; const b = aabbBounds; points.forEach(p => { + if (p.defined === false) { + return; + } b.add(p.x, p.y); b.add(p.x1 ?? p.x, p.y1 ?? p.y); //面积图特殊性:由三个值构成,横向面积图,x1会省略;纵向面积图,y1会省略 }); @@ -103,6 +106,9 @@ export class Area extends Graphic implements IArea { const b = aabbBounds; segments.forEach(s => { s.points.forEach(p => { + if (p.defined === false) { + return; + } b.add(p.x, p.y); b.add(p.x1 ?? p.x, p.y1 ?? p.y); //面积图特殊性:由三个值构成,横向面积图,x1会省略;纵向面积图,y1会省略 }); diff --git a/packages/vrender-core/src/graphic/line.ts b/packages/vrender-core/src/graphic/line.ts index b69d6a2c8..33d3c2440 100644 --- a/packages/vrender-core/src/graphic/line.ts +++ b/packages/vrender-core/src/graphic/line.ts @@ -82,10 +82,10 @@ export class Line extends Graphic implements ILine { aabbBounds: IAABBBounds, graphic?: ILine ): IAABBBounds { - const { points = lineTheme.points, connectedType } = attribute; + const { points = lineTheme.points } = attribute; const b = aabbBounds; points.forEach(p => { - if (p.defined !== false || connectedType === 'connect') { + if (p.defined !== false) { b.add(p.x, p.y); } }); @@ -97,11 +97,11 @@ export class Line extends Graphic implements ILine { aabbBounds: IAABBBounds, graphic?: ILine ): IAABBBounds { - const { segments = lineTheme.segments, connectedType } = attribute; + const { segments = lineTheme.segments } = attribute; const b = aabbBounds; segments.forEach(s => { s.points.forEach(p => { - if (p.defined !== false || connectedType === 'connect') { + if (p.defined !== false) { b.add(p.x, p.y); } }); From 15417547d06b906d1cc26c59f381cdc047300f4c Mon Sep 17 00:00:00 2001 From: xile611 Date: Wed, 16 Sep 2026 14:27:06 +0800 Subject: [PATCH 09/18] fix(area): exclude undefined points from area geometry (cherry picked from commit 45fd2eb01ee38c9c20c2d4b4522ad96505d8411b) --- .../2026-09-16-invalid-point-bounds-fix.md | 122 +++++++++++ .../area-invalid-point-incremental.test.ts | 105 ++++++++++ .../graphic/area-invalid-point-render.test.ts | 190 ++++++++++++++++++ .../__tests__/graphic/area-test-utils.ts | 74 +++++++ .../vrender-core/src/common/area-cache.ts | 172 ++++++++++++++++ .../vrender-core/src/common/render-curve.ts | 50 ++--- packages/vrender-core/src/graphic/area.ts | 9 +- .../contributions/render/area-render.ts | 140 +------------ .../render/incremental-area-render.ts | 43 +++- .../graphic/area-invalid-point.test.ts | 94 +++++++++ 10 files changed, 834 insertions(+), 165 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-16-invalid-point-bounds-fix.md create mode 100644 packages/vrender-core/__tests__/graphic/area-invalid-point-incremental.test.ts create mode 100644 packages/vrender-core/__tests__/graphic/area-invalid-point-render.test.ts create mode 100644 packages/vrender-core/__tests__/graphic/area-test-utils.ts create mode 100644 packages/vrender-core/src/common/area-cache.ts create mode 100644 packages/vrender/__tests__/graphic/area-invalid-point.test.ts diff --git a/docs/superpowers/plans/2026-09-16-invalid-point-bounds-fix.md b/docs/superpowers/plans/2026-09-16-invalid-point-bounds-fix.md new file mode 100644 index 000000000..6348882f2 --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-invalid-point-bounds-fix.md @@ -0,0 +1,122 @@ +# Invalid Point Bounds 修复计划与收敛结果 + +基线:`fix/invalid-point-bounds-1.0`,HEAD `b568bb44ce006f821decf514d1187fd18bb21111`,PR #2117。 + +2026-09-16 按用户要求收敛。本记录替代此前范围较大的实施记录;当前修改只保留 area 缺失点所需的路径组织、缓存失效和增量入口修复。 + +## 目标与根因 + +保留 PR 排除 `defined: false` 点的 bounds 行为,让实际填充也不受这些坐标影响。原实现的两处问题已用真实 Canvas 复现: + +1. `basis + connectedType: none`:插值器仍读取无效点,导致有效邻段越界。 +2. 首个 styled segment 只有一个无效点:该点被当成后续 top 起点,bottom 又独立使用原始点,产生错误填充和上下边界错配。 + +两例的收紧 bounds 均不包含 `(95,20)`,旧实际填充却覆盖该位置,造成漏拾取和脏区风险。修复落在插值前的输入组织;不恢复无效点 bounds、不增加 padding、不关闭剔除。 + +复现数据(有效点下边界均为 `y1: 0`): + +- basis:`(0,0), (10,10), undefined(500,500,y1=-500), (20,10), (30,0)`。 +- connect 分段:第一段为 `undefined(500,500,y1=-500)`;第二段为 `(0,0), (10,10)`。 + +验收同时确认有效位置仍被填充,不能靠整图不绘制消除越界。 + +## 收敛范围 + +| 保留 | 必要性 | +| ------------------------------- | ----------------------------------------------------------------- | +| area 专用有效区间编译 | none 在缺失点处分段;connect 跳过缺失点;上下边界选取同一组有效点 | +| 原始 segment 索引和有效首尾方向 | 过滤后保持样式归属,避免无效坐标影响裁剪方向 | +| `connectedType` 的 shape 失效 | 同一图形切换 none/connect 时重建对应路径 | +| 增量 area 同类修复 | 上下边界均忽略无效坐标,跨批次只承接有效点 | + +| 从本次移出 | 当前处理 | +| ------------------------------------ | ---------------------------------------------------- | +| 共享曲线/area 裁剪的零投影、NaN 修正 | `drawSegments` 和 `render-area.ts` 恢复到 HEAD | +| 全有效 closed/Catmull–Rom 的行为修正 | 保留既有 `startPoint` 和闭合承接语义,以基线对照验收 | +| `closePath` 缓存失效补充 | 留待独立问题处理 | +| incremental WeakMap 连续性状态 | 删除;有有效数据要绘制时才向前查找最近有效点 | +| 增量下边界 offset 修正 | 保持既有行为,留待独立修复 | + +最终涉及 5 个产品源码文件(含 1 个新增内部 helper)。收敛针对行为和状态管理范围,代码行数没有大幅减少;未同时保留新旧两套 area 编译器。 + +## 实现边界 + +- `common/area-cache.ts` 在缓存重建时选择有效区间,再调用现有曲线生成器。全有效区间直接复用原始 points 数组。 +- 一个样式段保持一个缓存项和一次绘制流程。多个区间以 `defined: false` 曲线分隔;分隔只使用相邻有效区间端点,不产生可见连接面。 +- 缓存仍兼容 `{top, bottom}` 及其数组形式,内部增加原始段索引与方向,不新增 package export。 +- top 沿用曲线生成器的 `startPoint` 参数,bottom 沿用逆序及 stepBefore/stepAfter 互换;不借机修正既有全有效曲线语义。 +- `area-render.ts` 消费成对缓存,保留全有效 linear 快速绘制入口。缓存后重绘不新增有效点分段遍历。 +- 增量入口继续只处理既有基础能力,不扩展曲线、clipRange 或拾取。跨空段/缺失段需要连接时向前定位有效点;纯缺失批次跳过历史查询,避免连续追加缺失批次反复扫描前缀。 +- 不修改调用方 points/segments,不增加持久连续性状态,不引入其他分支架构。 + +## 实施与验收 + +- [x] 两处真实 Canvas 回归:异常远点不填充、有效区间仍填充、none 缺口为空。 +- [x] none/connect、有效/无效单点、空段、连续缺失、样式映射、上下边界一致性。 +- [x] 11 种曲线的缺失点等价性,clipRange、横纵方向、上下边单独描边。 +- [x] `connectedType` 更新刷新缓存;纯重绘和 clipRange 更新复用缓存。 +- [x] 增量跨批次与普通 linear 像素对照、多图形交错、替换数据、纯缺失批次扫描计数。 +- [x] Stage 拾取、平移、局部重绘与全量重绘像素对照、rough 缓存输入兼容。 +- [x] 全有效路径与原始 HEAD 隔离工作区比较:**704/704 组绘制命令一致**。覆盖 11 种曲线 × 4 个 clipRange × 2 种连接模式 × 2 个方向 × 4 种布局;布局包括非分段、普通分段、首段单点及三段承接。 +- [x] core 全量:**7 suites / 78 tests 通过**。 +- [x] vrender 定向回归:**4 suites / 10 tests 通过**。 +- [x] core 无增量类型检查、跨包 compile 通过;ESLint 0 errors,保留 12 条既有 warning;Prettier 检查通过。 +- [ ] 远端 Bugserver 用例登记及本次修复的视觉 CI 验证。 + +全有效对照与定向性能脚本保存在本机 `/tmp/vrender-2117-narrow-verification-benchmark.test.ts`,未把依赖绝对工作区路径的临时测试留在仓库。对照结果为 `/tmp/vrender-2117-narrow-path-comparison.json`。 + +## 定向性能证据 + +原生 Canvas、1000×120 画布,1k/10k 点;cold 包含新图形及缓存生成,cached 复用缓存。预热 10 次,交替顺序运行 7 轮,记录每次 draw 的中位数和 min/max。basis 每 17 点有一个缺失点,分段 linear 每段 100 点。 + +首次测量波动较大,完成其他验证后单独复测一次。下面同时保留两次结果,避免只挑较快数据;数值为修复版相对基线的中位数时间变化。 + +| 场景 | 首次 cold / cached | 复测 cold / cached | +| --------------- | ------------------ | ------------------ | +| 1k linear | +1.2% / -4.0% | -4.3% / +10.0% | +| 1k basis 缺失 | +9.6% / -8.0% | +1.6% / +4.1% | +| 1k 分段 linear | -11.5% / +3.0% | +23.5% / -6.9% | +| 10k linear | -2.7% / +3.7% | +3.2% / -8.4% | +| 10k basis 缺失 | -8.1% / +6.9% | -4.0% / -1.9% | +| 10k 分段 linear | +5.4% / +9.1% | -1.7% / +1.4% | + +复测 10k 分段 linear 的 cold 为 2.156 → 2.119 ms,cached 为 1.538 → 1.560 ms。两次各场景的 min/max 均与基线重叠,部分变化方向反转,未确认稳定退化;这不是性能无回归证明,也不能代替浏览器整页测量。原始数据为 `/tmp/vrender-2117-narrow-performance-first.json` 和 `/tmp/vrender-2117-narrow-performance.json`。 + +## 可重复验证命令 + +在 `packages/vrender-core`: + +```sh +./node_modules/.bin/jest -c jest.config.js --runInBand +./node_modules/.bin/tsc --noEmit --incremental false --composite false --pretty false +./node_modules/.bin/eslint src/common/area-cache.ts src/common/render-curve.ts src/graphic/area.ts src/render/contributions/render/area-render.ts src/render/contributions/render/incremental-area-render.ts +``` + +在仓库根目录: + +```sh +node common/scripts/install-run-rush.js compile -t @visactor/vrender +``` + +在 `packages/vrender`: + +```sh +./node_modules/.bin/jest -c jest.config.js --runInBand __tests__/graphic/area-invalid-point.test.ts __tests__/graphic/graphic-bounds.test.ts __tests__/core/graphic-bounds.test.ts __tests__/core/stage.test.ts +``` + +## 剩余边界 + +本次修复保证无效坐标不参与实际 area 几何;不解决全有效曲线自身的过冲、既有 closed/Catmull–Rom 分段问题、零投影裁剪 NaN 或增量 offset 问题。 + +本地没有 `BUG_SERVER_TOKEN`,未登记远端 case。此前查询到的 #2117 历史 CI 结果不包含本次工作区修改,不能用作本次通过的证据。合并前仍需完成远端视觉检查。 + +本记录描述本地验证完成时的结果,后续提交与推送以 Git 历史为准。未修改其他任务的 `2026-09-16-brush-initial-mask.md`。 + +## Develop 移植(2026-09-16) + +基于远端 develop `3c80bbdf1c10b9b4c32abb4c152f9d8e676d72f5`,依次 cherry-pick `b568bb44` 和 `45fd2eb01`。上文的 1.0.x 验证记录保留为来源证据,不代表 develop 的全量测试结果。 + +- 解决两处导入冲突,保留 develop 已移除 DI 装饰器的 renderer 实现。 +- 新增像素回归测试显式加载现有真实 Canvas 测试适配,避免 develop 的默认 mock 令像素/命中断言失去意义。 +- Stage 集成测试复用 develop 的 `createBrowserStage` 工具,遵循当前 App 初始化与释放方式。 +- 移植后定向验证:core 3 suites / 50 tests、vrender 1 suite / 4 tests 及跨包 compile 均通过。全包测试由推送钩子运行,最终结果记录在 PR。 diff --git a/packages/vrender-core/__tests__/graphic/area-invalid-point-incremental.test.ts b/packages/vrender-core/__tests__/graphic/area-invalid-point-incremental.test.ts new file mode 100644 index 000000000..91cf5a384 --- /dev/null +++ b/packages/vrender-core/__tests__/graphic/area-invalid-point-incremental.test.ts @@ -0,0 +1,105 @@ +import type { IAreaSegment, IDrawContext } from '../../src/interface'; +import { Area } from '../../src/graphic/area'; +import { DefaultIncrementalCanvasAreaRender } from '../../src/render/contributions/render/incremental-area-render'; +import { basisPoints, createAreaContext, renderArea } from './area-test-utils'; + +const renderer = new DefaultIncrementalCanvasAreaRender({ getContributions: () => [] }); + +function drawBatch(area: Area, record: ReturnType, startAtIdx: number, length: number) { + area.incremental = 1; + renderer.drawShape(area, record.context, 0, 0, { + context: record.context, + multiGraphicOptions: { startAtIdx, length } + } as IDrawContext); +} + +function pixels(record: ReturnType) { + return Array.from(record.nativeContext.getImageData(0, 0, 120, 30).data); +} + +describe('incremental area missing-data continuity', () => { + test.each(['none', 'connect'] as const)( + '%s matches ordinary linear area across missing segments and batches', + connectedType => { + const segments: IAreaSegment[] = [ + { points: [basisPoints[2]] }, + { points: basisPoints.slice(0, 2) }, + { points: [] }, + { points: [basisPoints[2]] }, + { points: [basisPoints[2]] }, + { points: basisPoints.slice(3) } + ]; + const area = new Area({ fill: 'red', connectedType, segments }); + const record = createAreaContext(); + for (let i = 0; i < segments.length; i++) { + drawBatch(area, record, i, 1); + } + expect(pixels(record)).toEqual(pixels(renderArea({ segments, connectedType }))); + } + ); + + test.each(['none', 'connect'] as const)( + '%s selects the same upper and lower points within a segment', + connectedType => { + const segments = [{ points: basisPoints }]; + const area = new Area({ fill: 'red', connectedType, segments }); + const record = createAreaContext(); + drawBatch(area, record, 0, 1); + expect(pixels(record)).toEqual(pixels(renderArea({ segments, connectedType }))); + } + ); + + test('interleaved graphics and replaced segments use their current data', () => { + const segments = [ + { points: basisPoints.slice(0, 2) }, + { points: [basisPoints[2]] }, + { points: basisPoints.slice(3) } + ]; + const first = new Area({ fill: 'red', connectedType: 'connect', segments }); + const other = new Area({ fill: 'red', connectedType: 'none', segments }); + const a = createAreaContext(); + const b = createAreaContext(); + for (let i = 0; i < segments.length; i++) { + drawBatch(first, a, i, 1); + drawBatch(other, b, i, 1); + } + expect(pixels(a)).toEqual(pixels(renderArea({ segments, connectedType: 'connect' }))); + expect(pixels(b)).toEqual(pixels(renderArea({ segments, connectedType: 'none' }))); + + const replacement = [{ points: basisPoints.slice(3) }]; + first.setAttributes({ segments: replacement, connectedType: 'none' }); + const restarted = createAreaContext(); + drawBatch(first, restarted, 0, 1); + expect(pixels(restarted)).toEqual(pixels(renderArea({ segments: replacement }))); + }); + + test('missing-only append batches do not repeatedly scan the prefix', () => { + let reads = 0; + const firstPoints = basisPoints.slice(0, 2); + Object.defineProperty(firstPoints, 1, { + get: () => { + reads++; + return basisPoints[1]; + } + }); + const segments = [{ points: firstPoints }]; + const area = new Area({ fill: 'red', connectedType: 'connect', segments }); + const record = createAreaContext(); + drawBatch(area, record, 0, 1); + const initialReads = reads; + for (let i = 0; i < 30; i++) { + segments.push({ points: [basisPoints[2]] }); + drawBatch(area, record, segments.length - 1, 1); + } + expect(reads).toBe(initialReads); + segments.push({ points: basisPoints.slice(3) }); + drawBatch(area, record, segments.length - 1, 1); + expect(reads).toBe(initialReads + 1); + expect(record.nativeContext.isPointInPath(15, 2)).toBe(true); + + area.setAttribute('connectedType', 'none'); + const changed = createAreaContext(); + drawBatch(area, changed, segments.length - 1, 1); + expect(changed.nativeContext.isPointInPath(15, 2)).toBe(false); + }); +}); diff --git a/packages/vrender-core/__tests__/graphic/area-invalid-point-render.test.ts b/packages/vrender-core/__tests__/graphic/area-invalid-point-render.test.ts new file mode 100644 index 000000000..d3ca5bfa5 --- /dev/null +++ b/packages/vrender-core/__tests__/graphic/area-invalid-point-render.test.ts @@ -0,0 +1,190 @@ +import type { IArea, ICurveType } from '../../src/interface'; +import type { IPointLike } from '@visactor/vutils'; +import { Area } from '../../src/graphic/area'; +import { calcLineCache } from '../../src/common/segment'; +import { drawAreaSegments } from '../../src/common/render-area'; +import { basisPoints, createAreaContext, renderArea } from './area-test-utils'; + +const curveTypes: ICurveType[] = [ + 'linear', + 'basis', + 'monotoneX', + 'monotoneY', + 'step', + 'stepBefore', + 'stepAfter', + 'stepClosed', + 'linearClosed', + 'catmullRom', + 'catmullRomClosed' +]; +const left = [ + { x: 0, y: 4, y1: 0 }, + { x: 4, y: 8, y1: 0 }, + { x: 8, y: 6, y1: 0 }, + { x: 12, y: 10, y1: 0 } +]; +const right = left.map(p => ({ ...p, x: p.x + 20 })); + +describe('area paths with undefined points', () => { + test('basis interpolation restarts at a gap before calculating either boundary', () => { + const { nativeContext, area } = renderArea({ points: basisPoints, curveType: 'basis', connectedType: 'none' }); + expect(nativeContext.isPointInPath(95, 20)).toBe(false); + expect(nativeContext.isPointInPath(5, 2)).toBe(true); + expect(nativeContext.isPointInPath(25, 2)).toBe(true); + expect(nativeContext.isPointInPath(15, 2)).toBe(false); + expect(area.AABBBounds.x2).toBe(30); + expect(area.AABBBounds.y2).toBe(10); + }); + + test('a leading undefined singleton cannot seed the next styled segment', () => { + const { nativeContext, area, fills } = renderArea({ + connectedType: 'connect', + segments: [ + { fill: 'blue', points: [basisPoints[2]] }, + { fill: 'green', points: basisPoints.slice(0, 2) } + ] + }); + expect(nativeContext.isPointInPath(95, 20)).toBe(false); + expect(nativeContext.isPointInPath(5, 2)).toBe(true); + expect(fills.map(attrs => attrs.fill)).toEqual(['green']); + expect(area.AABBBounds.x2).toBe(10); + expect(area.AABBBounds.y2).toBe(10); + }); + + test.each(curveTypes)('%s uses the same geometry as independently selected valid points', curveType => { + const points = [...left, basisPoints[2], ...right]; + const actual = renderArea({ points, curveType, connectedType: 'none' }); + const first = renderArea({ points: left, curveType }); + const second = renderArea({ points: right, curveType }); + for (let x = 0.5; x < 34; x += 1) { + for (let y = 0.5; y < 12; y += 1) { + expect(actual.nativeContext.isPointInPath(x, y)).toBe( + first.nativeContext.isPointInPath(x, y) || second.nativeContext.isPointInPath(x, y) + ); + } + } + expect(renderArea({ points, curveType, connectedType: 'connect' }).commands).toEqual( + renderArea({ points: [...left, ...right], curveType, connectedType: 'connect' }).commands + ); + }); + + test.each(curveTypes)('%s ignores missing coordinates for clipping and both stroke boundaries', curveType => { + for (const connectedType of ['none', 'connect'] as const) { + for (const clipRange of [0, 0.5, 1]) { + for (const vertical of [false, true]) { + const valid = vertical ? [...left, ...right].map(p => ({ x: p.y, y: p.x, x1: 0 })) : [...left, ...right]; + const points: IPointLike[] = [ + basisPoints[2], + ...valid.slice(0, 4), + basisPoints[2], + ...valid.slice(4), + basisPoints[2] + ]; + const attrs = { points, curveType, connectedType, clipRange, stroke: [true, false, false] }; + const actual = renderArea(attrs); + const displaced = renderArea({ + ...attrs, + points: points.map(p => (p.defined === false ? { x: NaN, y: NaN, x1: NaN, y1: NaN, defined: false } : p)) + }); + expect(actual.commands).toEqual(displaced.commands); + // Zero-projection clipping behavior is outside this missing-data fix. + if (clipRange === 1) { + expect(actual.commands.every(([, ...args]) => args.every(Number.isFinite))).toBe(true); + } + expect(renderArea({ ...attrs, stroke: [false, false, true] }).commands).toEqual( + renderArea({ ...attrs, points: displaced.area.attribute.points, stroke: [false, false, true] }).commands + ); + } + } + } + }); + + test.each(curveTypes)('%s preserves the existing all-defined styled segment contract', curveType => { + const actual = renderArea({ segments: [{ points: left }, { points: right }], curveType }); + const previous = calcLineCache(left, curveType); + const top = calcLineCache(right, curveType, { startPoint: { x: previous.endX, y: previous.endY } }); + const bottomPoints = [left[left.length - 1], ...right].reverse().map(p => ({ x: p.x, y: p.y1 })); + const bottomType = curveType === 'stepBefore' ? 'stepAfter' : curveType === 'stepAfter' ? 'stepBefore' : curveType; + const bottom = calcLineCache(bottomPoints, bottomType); + const expected = createAreaContext(); + expected.context.beginPath(); + drawAreaSegments(expected.context, { top, bottom }, 1); + const lastBegin = actual.commands.map(command => command[0]).lastIndexOf('beginPath'); + expect(actual.commands.slice(lastBegin)).toEqual(expected.commands); + }); + + test.each(['none', 'connect'] as const)( + 'empty and missing segments keep styles aligned in %s mode', + connectedType => { + const { fills, nativeContext } = renderArea({ + connectedType, + segments: [ + { fill: 'empty', points: [] }, + { fill: 'invalid', points: [basisPoints[2]] }, + { fill: 'seed', points: [left[0]] }, + { fill: 'green', points: left.slice(1) }, + { fill: 'empty', points: [] }, + { fill: 'invalid', points: [basisPoints[2], basisPoints[2]] }, + { fill: 'blue', points: right } + ] + }); + expect(fills.map(attrs => attrs.fill)).toEqual(['green', 'blue']); + expect(nativeContext.isPointInPath(25, 2)).toBe(true); + expect(nativeContext.isPointInPath(95, 20)).toBe(false); + } + ); + + test('a trailing missing point clears continuity only in none mode', () => { + const segments = [{ points: [...left, basisPoints[2]] }, { points: right }]; + expect(renderArea({ segments, connectedType: 'none' }).nativeContext.isPointInPath(16, 2)).toBe(false); + expect(renderArea({ segments, connectedType: 'connect' }).nativeContext.isPointInPath(16, 2)).toBe(true); + }); + + test('all missing points and singleton runs clear previously rendered geometry', () => { + const area = new Area({ fill: 'red', points: basisPoints, curveType: 'basis' }); + renderArea(area); + for (const points of [[], [basisPoints[2]], [left[0], basisPoints[2], right[0]]]) { + area.setAttribute('points', points); + const result = renderArea(area); + expect(result.fills).toHaveLength(0); + expect(result.nativeContext.isPointInPath(5, 2)).toBe(false); + } + }); + + test('connection mode changes rebuild the cache without replacing points', () => { + const area = new Area({ fill: 'red', points: basisPoints, connectedType: 'none' }); + expect(renderArea(area).nativeContext.isPointInPath(15, 2)).toBe(false); + area.setAttribute('connectedType', 'connect'); + expect(renderArea(area).nativeContext.isPointInPath(15, 2)).toBe(true); + area.setAttribute('connectedType', 'none'); + expect(renderArea(area).nativeContext.isPointInPath(15, 2)).toBe(false); + }); + + test('geometry attributes rebuild caches while repeated draws and clip updates reuse them', () => { + const area = new Area({ fill: 'red', points: basisPoints, curveType: 'basis' }); + renderArea(area); + let cache = (area as IArea).cacheArea; + renderArea(area); + area.setAttribute('clipRange', 0.5); + renderArea(area); + expect((area as IArea).cacheArea).toBe(cache); + for (const attrs of [ + { curveType: 'linear' as const }, + { curveTension: 0.7 }, + { points: [...basisPoints] }, + { segments: [{ points: basisPoints }] } + ]) { + area.setAttributes(attrs); + renderArea(area); + expect((area as IArea).cacheArea).not.toBe(cache); + cache = (area as IArea).cacheArea; + } + }); + + test('valid input arrays and points remain owned by the caller', () => { + const points = [...left, basisPoints[2], ...right].map(p => Object.freeze({ ...p })); + Object.freeze(points); + expect(() => renderArea({ points, curveType: 'basis' })).not.toThrow(); + }); +}); diff --git a/packages/vrender-core/__tests__/graphic/area-test-utils.ts b/packages/vrender-core/__tests__/graphic/area-test-utils.ts new file mode 100644 index 000000000..cc4a0d2d7 --- /dev/null +++ b/packages/vrender-core/__tests__/graphic/area-test-utils.ts @@ -0,0 +1,74 @@ +// Pixel and hit-test assertions require a real Canvas instead of the default mock. +import '../../../../share/jest-config/setup-jsdom-canvas'; +import '../../src/modules'; +import type { IAreaGraphicAttribute, IContext2d, IDrawContext } from '../../src/interface'; +import { Area } from '../../src/graphic/area'; +import { DefaultCanvasAreaRender } from '../../src/render/contributions/render/area-render'; + +export const areaRenderer = new DefaultCanvasAreaRender({ getContributions: () => [] }); + +export function createAreaContext() { + const canvas = document.createElement('canvas'); + canvas.width = 160; + canvas.height = 80; + const nativeContext = canvas.getContext('2d'); + const commands: Array<[string, ...number[]]> = []; + const fills: IAreaGraphicAttribute[] = []; + let attribute: IAreaGraphicAttribute; + const context = { + nativeContext, + beginPath() { + commands.push(['beginPath']); + nativeContext.beginPath(); + }, + moveTo(x: number, y: number) { + commands.push(['moveTo', x, y]); + nativeContext.moveTo(x, y); + }, + lineTo(x: number, y: number) { + commands.push(['lineTo', x, y]); + nativeContext.lineTo(x, y); + }, + bezierCurveTo(...args: [number, number, number, number, number, number]) { + const coordinates = args.slice(0, 6) as typeof args; + commands.push(['bezierCurveTo', ...coordinates]); + nativeContext.bezierCurveTo(...coordinates); + }, + closePath() { + commands.push(['closePath']); + nativeContext.closePath(); + }, + setShadowBlendStyle() { + // Geometry assertions use the native context's default shadow and blend settings. + }, + setCommonStyle(_area: Area, attrs: IAreaGraphicAttribute) { + attribute = attrs; + }, + setStrokeStyle() { + // The harness records stroke geometry without applying attribute styles. + }, + fill() { + fills.push(attribute); + nativeContext.fill(); + }, + stroke() { + nativeContext.stroke(); + } + }; + return { context: context as unknown as IContext2d, nativeContext, commands, fills }; +} + +export function renderArea(attribute: IAreaGraphicAttribute | Area, x = 0, y = 0) { + const area = attribute instanceof Area ? attribute : new Area({ fill: 'red', ...attribute }); + const record = createAreaContext(); + areaRenderer.drawShape(area, record.context, x, y, { context: record.context } as IDrawContext); + return { ...record, area }; +} + +export const basisPoints = [ + { x: 0, y: 0, y1: 0 }, + { x: 10, y: 10, y1: 0 }, + { x: 500, y: 500, y1: -500, defined: false }, + { x: 20, y: 10, y1: 0 }, + { x: 30, y: 0, y1: 0 } +]; diff --git a/packages/vrender-core/src/common/area-cache.ts b/packages/vrender-core/src/common/area-cache.ts new file mode 100644 index 000000000..b429b1c8a --- /dev/null +++ b/packages/vrender-core/src/common/area-cache.ts @@ -0,0 +1,172 @@ +import { abs, type IPointLike } from '@visactor/vutils'; +import type { IAreaCacheItem, IAreaSegment, ICurveType, IDirection, ISegPath2D } from '../interface'; +import { Direction } from './enums'; +import { calcLineCache } from './segment'; +import type { SegContext } from './seg-context'; +import { LineCurve } from './segment/curve/line'; + +export interface AreaRenderCacheItem extends IAreaCacheItem { + sourceSegmentIndex: number; + direction: IDirection; +} + +/** Select both boundaries together, before interpolation can read undefined coordinates. */ +export function getAreaPointRuns(points: IPointLike[], connectedType: 'none' | 'connect', startPoint?: IPointLike) { + const runs: IPointLike[][] = []; + if (!points.some(p => p.defined === false)) { + const run = startPoint ? [startPoint, ...points] : points; + if (run.length) { + runs.push(run); + } + return { runs, tail: run[run.length - 1] }; + } + + let run: IPointLike[] = startPoint ? [startPoint] : []; + for (let i = 0; i < points.length; i++) { + const point = points[i]; + if (point.defined !== false) { + run.push(point); + } else if (connectedType !== 'connect') { + if (run.length) { + runs.push(run); + } + run = []; + } + } + if (run.length) { + runs.push(run); + } + return { runs, tail: run[run.length - 1] }; +} + +/** Join completed caches without restarting interpolation across a missing-data gap. */ +function joinAreaPaths(paths: ISegPath2D[]): ISegPath2D { + if (paths.length === 1) { + return paths[0]; + } + const curves: ISegPath2D['curves'] = []; + for (let i = 0; i < paths.length; i++) { + const next = paths[i].curves; + if (curves.length) { + const previous = curves[curves.length - 1]; + const gap = new LineCurve(previous.p3 ?? previous.p1, next[0].p0); + gap.defined = false; + gap.originP1 = previous.originP2; + gap.originP2 = next[0].originP1; + curves.push(gap); + } + for (let j = 0; j < next.length; j++) { + curves.push(next[j]); + } + } + // These are completed, read-only drawing caches. Reuse the final context so its + // endX/endY still describe the final curve, without copying curve objects. + const path = paths[paths.length - 1] as SegContext; + path.curves = curves; + path.length = NaN; + return path; +} + +function compileAreaRuns( + runs: IPointLike[][], + curveType: ICurveType, + curveTension: number, + sourceSegmentIndex: number, + topStart?: IPointLike, + bottomStart?: IPointLike +): AreaRenderCacheItem | null { + const tops: ISegPath2D[] = []; + const bottoms: ISegPath2D[] = []; + const bottomType = curveType === 'stepBefore' ? 'stepAfter' : curveType === 'stepAfter' ? 'stepBefore' : curveType; + for (let i = 0; i < runs.length; i++) { + const points = runs[i]; + const startPoint = i === 0 ? topStart : undefined; + if (points.length < 2 - Number(!!startPoint)) { + continue; + } + const bottomPoints: IPointLike[] = []; + for (let j = points.length - 1; j >= 0; j--) { + const p = points[j]; + bottomPoints.push({ x: p.x1 ?? p.x, y: p.y1 ?? p.y }); + } + if (i === 0 && bottomStart) { + bottomPoints.push({ x: bottomStart.x1 ?? bottomStart.x, y: bottomStart.y1 ?? bottomStart.y }); + } + // Preserve the curve generators' existing startPoint/closure semantics. In + // particular, don't prepend a styled segment's startPoint to its input array. + const top = calcLineCache(points, curveType, { startPoint, curveTension }); + const bottom = calcLineCache(bottomPoints, bottomType, { curveTension }); + if (top?.curves.length && bottom?.curves.length) { + tops.push(top); + bottoms.push(bottom); + } + } + if (!tops.length) { + return null; + } + return { + top: joinAreaPaths(tops), + bottom: joinAreaPaths(bottoms.reverse()), + sourceSegmentIndex, + direction: Direction.ROW + }; +} + +export function calcAreaCache( + points: IPointLike[] | undefined, + segments: IAreaSegment[] | undefined, + curveType: ICurveType, + connectedType: 'none' | 'connect', + curveTension: number +): AreaRenderCacheItem | AreaRenderCacheItem[] | null { + const caches: AreaRenderCacheItem[] = []; + let tail: IPointLike; + let topTail: IPointLike; + let first: IPointLike; + let last: IPointLike; + const count = segments ? segments.length : 1; + for (let i = 0; i < count; i++) { + const segmentPoints = segments ? segments[i].points : points ?? []; + const result = getAreaPointRuns(segmentPoints, connectedType); + const canContinue = connectedType === 'connect' || segmentPoints[0]?.defined !== false; + if (result.runs.length) { + first = first ?? result.runs[0][0]; + const lastRun = result.runs[result.runs.length - 1]; + last = lastRun[lastRun.length - 1]; + } + const cache = compileAreaRuns( + result.runs, + curveType, + curveTension, + i, + canContinue ? topTail : undefined, + canContinue ? tail : undefined + ); + if (cache) { + caches.push(cache); + } + if (result.tail) { + tail = result.tail; + const lastRun = result.runs[result.runs.length - 1]; + topTail = + cache && (result.runs.length === 1 || lastRun.length > 1) ? { x: cache.top.endX, y: cache.top.endY } : tail; + } else if (connectedType !== 'connect' && segmentPoints.length) { + tail = topTail = undefined; + } + } + if (!caches.length) { + return null; + } + let direction = Direction.ROW; + if (last.x1 != null) { + const dx = abs(last.x - first.x); + const dy = abs(last.y - first.y); + if (last.y1 == null || (Number.isFinite(dx + dy) && dy >= dx)) { + direction = Direction.COLUMN; + } + } + for (let i = 0; i < caches.length; i++) { + caches[i].direction = direction; + } + return segments ? caches : caches[0]; +} diff --git a/packages/vrender-core/src/common/render-curve.ts b/packages/vrender-core/src/common/render-curve.ts index 6c870ab9a..c83f7e4fa 100644 --- a/packages/vrender-core/src/common/render-curve.ts +++ b/packages/vrender-core/src/common/render-curve.ts @@ -11,6 +11,7 @@ import type { } from '../interface'; import { Direction } from './enums'; import { drawSegItem } from './render-utils'; +import { getAreaPointRuns } from './area-cache'; function drawEachCurve( path: IPath2D, @@ -212,40 +213,29 @@ export function drawIncrementalAreaSegments( params?: { offsetX?: number; offsetY?: number; + connectedType?: 'none' | 'connect'; + startPoint?: IPointLike; } ) { - const { offsetX = 0, offsetY = 0 } = params || {}; - const { points } = segments; - // 分段 - const definedPointsList: IPointLike[][] = []; - let lastIdx = 0; - for (let i = 0; i < points.length; i++) { - if (points[i].defined === false) { - if (lastIdx + 1 !== i) { - definedPointsList.slice(lastIdx, i); - } - lastIdx = i; + const { offsetX = 0, offsetY = 0, connectedType = 'none' } = params || {}; + const startPoint = + params && 'startPoint' in params + ? params.startPoint + : lastSeg && getAreaPointRuns(lastSeg.points, connectedType).tail; + const { runs } = getAreaPointRuns(segments.points, connectedType, startPoint); + for (let i = 0; i < runs.length; i++) { + const points = runs[i]; + if (points.length < 2) { + continue; } - } - definedPointsList.length === 0; - definedPointsList.push(points); - definedPointsList.forEach((points, i) => { - const startP = lastSeg && i === 0 ? lastSeg.points[lastSeg.points.length - 1] : points[0]; - path.moveTo(startP.x + offsetX, startP.y + offsetY); - // 绘制上层 - points.forEach(p => { - if (p.defined === false) { - path.moveTo(p.x + offsetX, p.y + offsetY); - return; - } - path.lineTo(p.x + offsetX, p.y + offsetY); - }); - // 绘制下层 - for (let i = points.length - 1; i >= 0; i--) { - const p = points[i]; + path.moveTo(points[0].x + offsetX, points[0].y + offsetY); + for (let j = 1; j < points.length; j++) { + path.lineTo(points[j].x + offsetX, points[j].y + offsetY); + } + for (let j = points.length - 1; j >= 0; j--) { + const p = points[j]; path.lineTo(p.x1 ?? p.x, p.y1 ?? p.y); } - path.lineTo(startP.x1 ?? startP.x, startP.y1 ?? startP.y); path.closePath(); - }); + } } diff --git a/packages/vrender-core/src/graphic/area.ts b/packages/vrender-core/src/graphic/area.ts index 74579ad78..cb6894257 100644 --- a/packages/vrender-core/src/graphic/area.ts +++ b/packages/vrender-core/src/graphic/area.ts @@ -7,7 +7,14 @@ import { getTheme } from './theme'; import { application } from '../application'; import { AREA_NUMBER_TYPE } from './constants'; -const AREA_UPDATE_TAG_KEY = ['segments', 'points', 'curveType', 'curveTension', ...GRAPHIC_UPDATE_TAG_KEY]; +const AREA_UPDATE_TAG_KEY = [ + 'segments', + 'points', + 'curveType', + 'curveTension', + 'connectedType', + ...GRAPHIC_UPDATE_TAG_KEY +]; export class Area extends Graphic implements IArea { type: 'area' = 'area'; diff --git a/packages/vrender-core/src/render/contributions/render/area-render.ts b/packages/vrender-core/src/render/contributions/render/area-render.ts index 9176d088a..6affdf48a 100644 --- a/packages/vrender-core/src/render/contributions/render/area-render.ts +++ b/packages/vrender-core/src/render/contributions/render/area-render.ts @@ -1,5 +1,4 @@ -import type { IPointLike } from '@visactor/vutils'; -import { abs, isArray, min } from '@visactor/vutils'; +import { isArray, min } from '@visactor/vutils'; import type { IArea, IAreaCacheItem, @@ -8,7 +7,6 @@ import type { IContext2d, IMarkAttribute, IThemeAttribute, - ISegPath2D, IAreaRenderContribution, IDrawContext, IRenderService, @@ -16,7 +14,7 @@ import type { IGraphicRenderDrawParams, IContributionProvider } from '../../../interface'; -import { calcLineCache } from '../../../common/segment'; +import { calcAreaCache, type AreaRenderCacheItem } from '../../../common/area-cache'; import { getTheme } from '../../../graphic/theme'; import { AreaRenderContribution } from './contributions/constants'; @@ -204,13 +202,6 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph curveType = 'linearClosed'; } - function parsePoint(points: IPointLike[], connectedType: 'none' | 'connect') { - if (connectedType !== 'connect') { - return points; - } - return points.filter(p => p.defined !== false); - } - if (clipRange === 1 && !segments && !points.some(p => p.defined === false) && curveType === 'linear') { return this.drawLinearAreaHighPerformance( area, @@ -229,105 +220,19 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph ); } - // 更新cache if (area.shouldUpdateShape()) { - if (segments && segments.length) { - let startPoint: IPointLike; - let lastTopSeg: { endX: number; endY: number }; - const topCaches = segments - .map((seg, index) => { - if (seg.points.length <= 1) { - // 第一个点的话,直接设置lastTopSeg - if (index === 0) { - seg.points[0] && (lastTopSeg = { endX: seg.points[0].x, endY: seg.points[0].y }); - return null; - } - } - // 添加上一个segment结束的点作为这个segment的起始点 - if (index === 1) { - startPoint = { x: lastTopSeg.endX, y: lastTopSeg.endY }; - } else if (index > 1) { - startPoint.x = lastTopSeg.endX; - startPoint.y = lastTopSeg.endY; - } - const data = calcLineCache(parsePoint(seg.points, connectedType), curveType, { - startPoint, - curveTension - }); - lastTopSeg = data; - return data; - }) - .filter(item => !!item); - let lastBottomSeg: ISegPath2D; - const bottomCaches = []; - for (let i = segments.length - 1; i >= 0; i--) { - const points = segments[i].points; - const bottomPoints: IPointLike[] = []; - for (let i = points.length - 1; i >= 0; i--) { - bottomPoints.push({ - x: points[i].x1 ?? points[i].x, - y: points[i].y1 ?? points[i].y - }); - } - // 处理一下bottom的segments,bottom的segments需要手动添加endPoints - if (i !== 0) { - const lastSegmentPoints = segments[i - 1].points; - const endPoint = lastSegmentPoints[lastSegmentPoints.length - 1]; - endPoint && - bottomPoints.push({ - x: endPoint.x1 ?? endPoint.x, - y: endPoint.y1 ?? endPoint.y - }); - } - if (bottomPoints.length > 1) { - lastBottomSeg = calcLineCache( - parsePoint(bottomPoints, connectedType), - curveType === 'stepBefore' ? 'stepAfter' : curveType === 'stepAfter' ? 'stepBefore' : curveType, - { curveTension } - ); - bottomCaches.unshift(lastBottomSeg); - } - } - area.cacheArea = bottomCaches.map((item, index) => ({ - top: topCaches[index], - bottom: item - })); - } else if (points && points.length) { - // 转换points - const topPoints = parsePoint(points, connectedType); - const bottomPoints: IPointLike[] = []; - for (let i = topPoints.length - 1; i >= 0; i--) { - bottomPoints.push({ - x: topPoints[i].x1 ?? topPoints[i].x, - y: topPoints[i].y1 ?? topPoints[i].y - }); - } - const topCache = calcLineCache(topPoints, curveType, { curveTension }); - const bottomCache = calcLineCache( - bottomPoints, - curveType === 'stepBefore' ? 'stepAfter' : curveType === 'stepAfter' ? 'stepBefore' : curveType, - { curveTension } - ); - - area.cacheArea = { top: topCache, bottom: bottomCache }; - } else { - area.cacheArea = null; - area.clearUpdateShapeTag(); - return; - } + area.cacheArea = calcAreaCache(points, segments, curveType, connectedType, curveTension); area.clearUpdateShapeTag(); } + if (!area.cacheArea) { + return; + } if (Array.isArray(area.cacheArea)) { - const segments = area.attribute.segments.filter(item => item.points.length); - // 如果第一个seg只有一个点,那么shift出去 - if (segments[0].points.length === 1) { - segments.shift(); - } if (clipRange === 1) { let skip = false; // 性能优化,不需要clip的线段不需要计算长度 - area.cacheArea.forEach((cache, index) => { + area.cacheArea.forEach(cache => { if (skip) { return; } @@ -338,7 +243,7 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph fillOpacity, doStroke, strokeOpacity, - segments[index], + segments[(cache as AreaRenderCacheItem).sourceSegmentIndex], [areaAttribute, area.attribute], clipRange, x, @@ -359,7 +264,7 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph // 直到上次绘制的长度 let drawedLengthUntilLast = 0; let skip = false; - area.cacheArea.forEach((cache, index) => { + area.cacheArea.forEach(cache => { if (skip) { return; } @@ -374,7 +279,7 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph fillOpacity, doStroke, strokeOpacity, - segments[index], + segments[(cache as AreaRenderCacheItem).sourceSegmentIndex], [areaAttribute, area.attribute], min(_cr, 1), x, @@ -519,30 +424,7 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph context.beginPath(); const ret: boolean = false; - const { points, segments } = area.attribute; - let direction = Direction.ROW; - let endP: IPointLike; - let startP: IPointLike; - if (segments) { - const endSeg = segments[segments.length - 1]; - const startSeg = segments[0]; - startP = startSeg.points[0]; - endP = endSeg.points[endSeg.points.length - 1]; - } else { - startP = points[0]; - endP = points[points.length - 1]; - } - const xTotalLength = abs(endP.x - startP.x); - const yTotalLength = abs(endP.y - startP.y); - if (endP.x1 == null) { - direction = Direction.ROW; - } else if (endP.y1 == null) { - direction = Direction.COLUMN; - } else if (!Number.isFinite(xTotalLength + yTotalLength)) { - direction = Direction.ROW; - } else { - direction = xTotalLength > yTotalLength ? Direction.ROW : Direction.COLUMN; - } + const direction = (cache as AreaRenderCacheItem).direction ?? cache.top.direction; drawAreaSegments(context, cache, clipRange, { offsetX, offsetY, diff --git a/packages/vrender-core/src/render/contributions/render/incremental-area-render.ts b/packages/vrender-core/src/render/contributions/render/incremental-area-render.ts index 17e713671..00e2be9c3 100644 --- a/packages/vrender-core/src/render/contributions/render/incremental-area-render.ts +++ b/packages/vrender-core/src/render/contributions/render/incremental-area-render.ts @@ -1,3 +1,4 @@ +import type { IPointLike } from '@visactor/vutils'; import type { IArea, IAreaGraphicAttribute, @@ -15,6 +16,23 @@ import { getTheme } from '../../../graphic/theme'; import { fillVisible, runFill } from './utils'; import { DefaultCanvasAreaRender } from './area-render'; import { drawIncrementalAreaSegments } from '../../../common/render-curve'; +import { getAreaPointRuns } from '../../../common/area-cache'; + +function previousPoint(segments: IAreaSegment[], index: number, connectedType: 'none' | 'connect') { + for (let i = index - 1; i >= 0; i--) { + const points = segments[i].points; + for (let j = points.length - 1; j >= 0; j--) { + const point = points[j]; + if (point.defined !== false) { + return point; + } + if (connectedType !== 'connect') { + return undefined; + } + } + } + return undefined; +} /** * 默认的基于canvas的line渲染器 @@ -48,7 +66,8 @@ export class DefaultIncrementalCanvasAreaRender extends DefaultCanvasAreaRender fill = areaAttribute.fill, fillOpacity = areaAttribute.fillOpacity, opacity = areaAttribute.opacity, - visible = areaAttribute.visible + visible = areaAttribute.visible, + connectedType = areaAttribute.connectedType } = area.attribute; // 不绘制或者透明 const fVisible = fillVisible(opacity, fillOpacity, fill); @@ -68,7 +87,12 @@ export class DefaultIncrementalCanvasAreaRender extends DefaultCanvasAreaRender } // 不支持clipRange,不支持pick,仅支持最基础的线段绘制 - for (let i = startAtIdx; i < startAtIdx + length; i++) { + const endIndex = Math.min(startAtIdx + length, segments.length); + for (let i = startAtIdx; i < endIndex; i++) { + // Empty batches draw nothing and must not repeatedly search the same prefix. + if (!segments[i].points.some(p => p.defined !== false)) { + continue; + } this.drawIncreaseSegment( area, context, @@ -77,7 +101,8 @@ export class DefaultIncrementalCanvasAreaRender extends DefaultCanvasAreaRender area.attribute.segments[i], [areaAttribute, area.attribute], x, - y + y, + { connectedType, startPoint: previousPoint(segments, i, connectedType) } ); } } else { @@ -93,16 +118,24 @@ export class DefaultIncrementalCanvasAreaRender extends DefaultCanvasAreaRender attribute: Partial, defaultAttribute: Required | Partial[], offsetX: number, - offsetY: number + offsetY: number, + continuity?: { connectedType: 'none' | 'connect'; startPoint?: IPointLike } ) { if (!seg) { return; } + const connectedType = + continuity?.connectedType ?? area.attribute.connectedType ?? getTheme(area).area.connectedType; + const startPoint = continuity + ? continuity.startPoint + : lastSeg && getAreaPointRuns(lastSeg.points, connectedType).tail; context.beginPath(); drawIncrementalAreaSegments(context.camera ? context : context.nativeContext, lastSeg, seg, { offsetX, - offsetY + offsetY, + connectedType, + startPoint }); // shadow diff --git a/packages/vrender/__tests__/graphic/area-invalid-point.test.ts b/packages/vrender/__tests__/graphic/area-invalid-point.test.ts new file mode 100644 index 000000000..61be20431 --- /dev/null +++ b/packages/vrender/__tests__/graphic/area-invalid-point.test.ts @@ -0,0 +1,94 @@ +// Pixel and hit-test assertions require a real Canvas instead of the default mock. +import '../../../../share/jest-config/setup-jsdom-canvas'; +import { createArea, CustomPath2D, type IAreaGraphicAttribute } from '../../src/index'; +import { RoughCanvasAreaRender } from '../../../vrender-kits/src/render/contributions/rough/rough-area'; +import { createBrowserStage } from '../util'; + +const points = [ + { x: 0, y: 0, y1: 0 }, + { x: 10, y: 10, y1: 0 }, + { x: 500, y: 500, y1: -500, defined: false }, + { x: 20, y: 10, y1: 0 }, + { x: 30, y: 0, y1: 0 } +]; + +function fixture(attrs: IAreaGraphicAttribute, dirty = false) { + const canvas = document.createElement('canvas'); + const stage = createBrowserStage({ canvas, width: 140, height: 80, dpr: 1, disableDirtyBounds: !dirty }); + const area = createArea({ x: 10, y: 20, fill: 'red', ...attrs }); + stage.defaultLayer.add(area); + stage.render(); + return { stage, area, context: canvas.getContext('2d') }; +} + +describe('area invalid points through public rendering and picking', () => { + test('basis gaps remain empty while valid translated regions can be picked', () => { + const { stage, area } = fixture({ points, curveType: 'basis', connectedType: 'none' }); + try { + const first = stage.pick(15, 22); + const second = stage.pick(35, 22); + const gap = stage.pick(25, 22); + const outside = stage.pick(105, 40); + expect(first && first.graphic).toBe(area); + expect(second && second.graphic).toBe(area); + expect(gap && gap.graphic).not.toBe(area); + expect(outside && outside.graphic).not.toBe(area); + area.setAttribute('connectedType', 'connect'); + stage.render(); + const connected = stage.pick(25, 22); + expect(connected && connected.graphic).toBe(area); + } finally { + stage.release(); + } + }); + + test('an invalid singleton segment cannot create an unpickable filled region', () => { + const { stage, area, context } = fixture({ + connectedType: 'connect', + segments: [{ points: [points[2]] }, { points: points.slice(0, 2) }] + }); + try { + const hit = stage.pick(15, 22); + expect(hit && hit.graphic).toBe(area); + const background = context.getImageData(130, 70, 1, 1).data; + expect(Array.from(context.getImageData(105, 40, 1, 1).data)).toEqual(Array.from(background)); + } finally { + stage.release(); + } + }); + + test('dirty rendering matches full rendering after valid and undefined coordinates change', () => { + const dirty = fixture({ points, curveType: 'basis' }, true); + const full = fixture({ points, curveType: 'basis' }); + try { + const updated = points.map(p => (p.defined === false ? { ...p, x: -800, y: -800 } : { ...p, x: p.x + 15 })); + dirty.area.setAttribute('points', updated); + full.area.setAttribute('points', updated); + dirty.stage.render(); + full.stage.render(); + const actual = dirty.context.getImageData(0, 0, 140, 80).data; + const expected = full.context.getImageData(0, 0, 140, 80).data; + expect(actual.every((value, i) => value === expected[i])).toBe(true); + } finally { + dirty.stage.release(); + full.stage.release(); + } + }); + + test('rough renderer receives disconnected valid subpaths through the existing cache contract', () => { + const renderer = new RoughCanvasAreaRender({ getContributions: () => [] }); + const { stage, area } = fixture({ points, curveType: 'basis' }); + const spy = jest.spyOn(CustomPath2D.prototype, 'toString'); + try { + const context = stage.window.getContext(); + renderer.drawShape(area, context, 0, 0, { context } as any); + const paths = spy.mock.results.map(result => result.value as string); + expect(paths).toHaveLength(1); + expect(paths[0].match(/M/g)).toHaveLength(2); + expect(paths[0]).not.toMatch(/500|NaN/); + } finally { + spy.mockRestore(); + stage.release(); + } + }); +}); From fc423f0825fb34d42a979fe9f34b49595f9e2702 Mon Sep 17 00:00:00 2001 From: xile611 Date: Thu, 17 Sep 2026 11:35:27 +0800 Subject: [PATCH 10/18] docs: plan manual Bug Server dispatch --- .../plans/2026-09-17-bug-server-dispatch.md | 43 +++++++++++++++++++ .../2026-09-17-bug-server-dispatch-design.md | 24 +++++++++++ 2 files changed, 67 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-17-bug-server-dispatch.md create mode 100644 docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md diff --git a/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md b/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md new file mode 100644 index 000000000..d8852e5d7 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md @@ -0,0 +1,43 @@ +# Bug Server Manual Dispatch Implementation Plan + +> Execute inline in this task; the workflow design was approved in the conversation. The referenced superpowers execution skills are not installed, so implementation uses the available repository tools. + +**Goal:** Allow maintainers to test an external PR at a reviewed SHA without creating a temporary PR. + +**Architecture:** Add manual-only validation, build, and submission jobs to the existing workflow. Keep the credential-bearing runner separate from PR code and use the workflow commit for trusted scripts. + +**Tech Stack:** GitHub Actions, actions/github-script, Node.js 24, existing TypeScript Bug Server client. + +## Global Constraints + +- Base branch: `develop`; implementation branch: `codex/bugserver-workflow-dispatch`. +- Inputs: `pr_number` and full `head_sha`. +- PR artifacts are data only in the submission job. +- Existing automatic workflows and Bug Server API protocol retain their behavior. + +## Task 1: Validate and resolve the manual target + +Files: `.github/scripts/bug-server-dispatch.cjs`, `.github/scripts/bug-server-dispatch.test.cjs`. + +- [ ] Write Node tests for valid fork PRs, invalid PR numbers, malformed SHAs, stale SHAs, wrong base repository and non-default workflow branches. +- [ ] Run `node --test .github/scripts/bug-server-dispatch.test.cjs` and confirm the missing module fails. +- [ ] Implement `resolveBugServerTarget({ github, context, prNumber, headSha })`, returning `{ prNumber, sha, headRef, prUrl }`. Validate locally before calling `github.rest.pulls.get`; compare the returned PR's repository and current head with the requested target. +- [ ] Rerun the tests. + +## Task 2: Isolate build and submission + +Files: `.github/workflows/bug-server.yml`. + +- [ ] Add the two string inputs, retain existing automatic build behind a non-dispatch condition, and add manual validation/build/submission jobs with read-only repository permissions. +- [ ] Checkout PR code by the validated SHA, verify `git rev-parse HEAD`, build with the existing Rush commands, and upload only the generated bundle. +- [ ] Checkout the trusted client by `github.workflow_sha` in the submission job. Install `node-fetch@2.6.6`, `form-data@4.0.6`, `ts-node@10.9.0`, and `typescript@4.9.5` outside the repository with lifecycle scripts disabled. +- [ ] Run the client through the isolated ts-node executable with explicit CommonJS/esModuleInterop compiler options and reviewed PR metadata; expose the secret only for this command. +- [ ] Run actionlint and simulate the trusted client against mocked API responses, verifying that the bundle is uploaded without execution. + +## Task 3: Document and verify + +Files: `tools/bugserver-trigger/README.md`. + +- [ ] Document UI and CLI invocation, default-branch availability, tested head versus merge semantics, metadata, and result logs. +- [ ] Review the final diff for secret exposure, event regressions and shell interpolation; run `git diff --check`. +- [ ] Record completed checks and deliver the local branch. Do not claim a live Bug Server run before the default-branch workflow exists. diff --git a/docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md b/docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md new file mode 100644 index 000000000..2c766b8d8 --- /dev/null +++ b/docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md @@ -0,0 +1,24 @@ +# Bug Server 手动触发设计 + +## 目标与已确认方案 + +维护者无需创建临时 PR,即可通过 `Bug Server CI` 的 `workflow_dispatch` 验证外部 PR。用户已确认输入 PR 编号与完整 head SHA,并要求构建和持有 token 的上传过程分离。本次从 `develop` 创建 `codex/bugserver-workflow-dispatch` 实现。 + +## 取舍 + +- 采用独立的手动验证、构建、上传 jobs,保留已有 push / pull_request 自动流程。 +- 不改用 `pull_request_target` 执行外部 PR 代码。 +- 不在单个 job 中先构建再注入 token:构建期间启动的进程可能继续存在。 + +## 数据流与边界 + +1. 只允许从默认分支运行手动入口。验证 job 从 workflow 的固定 SHA 检出可信校验脚本。 +2. 校验 PR 编号、40 位十六进制 SHA,并通过 GitHub API 确认 SHA 等于该 PR 当前 head。错误在构建前终止。 +3. 构建 job 在独立 runner 中检出已验证的固定 SHA,不持有 Bug Server secret、不保留 checkout 凭据、不使用共享缓存。只上传 `tools/bugserver-trigger/dist/index.js`。 +4. 上传 job 在新的 runner 中检出 workflow SHA 对应的可信触发脚本。独立安装该脚本所需的固定版本依赖,不执行 PR 的安装脚本或产物。产物仅作为文件上传。 +5. 仅调用触发脚本的 step 注入 `BUG_SERVER_TOKEN`。传给现有脚本的提交、PR ref、源分支信息来自验证 job,避免记录成 develop 的提交。 +6. Actions summary 记录 PR 与测试 SHA;现有脚本继续输出 `scmVersion`、`bundleId` 和用例结果。 + +## 验证 + +使用 Node 内置测试覆盖输入校验、默认分支限制、SHA 不匹配以及 fork PR 成功解析;actionlint 校验 workflow。以本地 mock HTTP 模拟上传、SCM 构建和图片测试,验证可信脚本的独立运行及元数据传递。线上触发需要入口合入默认分支后执行。 From 95514c67ed0c2bee3b1a45641596c168b48a05b2 Mon Sep 17 00:00:00 2001 From: xile611 Date: Thu, 17 Sep 2026 11:39:21 +0800 Subject: [PATCH 11/18] feat: add manual Bug Server validation for fork PRs --- .github/scripts/bug-server-dispatch.cjs | 36 +++++ .github/scripts/bug-server-dispatch.test.cjs | 84 +++++++++++ .github/workflows/bug-server.yml | 139 +++++++++++++++++- .../plans/2026-09-17-bug-server-dispatch.md | 31 ++-- tools/bugserver-trigger/README.md | 43 ++++++ 5 files changed, 320 insertions(+), 13 deletions(-) create mode 100644 .github/scripts/bug-server-dispatch.cjs create mode 100644 .github/scripts/bug-server-dispatch.test.cjs create mode 100644 tools/bugserver-trigger/README.md diff --git a/.github/scripts/bug-server-dispatch.cjs b/.github/scripts/bug-server-dispatch.cjs new file mode 100644 index 000000000..401828e2b --- /dev/null +++ b/.github/scripts/bug-server-dispatch.cjs @@ -0,0 +1,36 @@ +async function resolveBugServerTarget({ github, context, prNumber, headSha }) { + const defaultBranch = context.payload.repository.default_branch; + if (context.ref !== `refs/heads/${defaultBranch}`) { + throw new Error(`Run this workflow from the default branch (${defaultBranch}).`); + } + if (!/^[1-9][0-9]*$/.test(prNumber) || !Number.isSafeInteger(Number(prNumber))) { + throw new Error('PR number must be a positive integer.'); + } + if (headSha.length !== 40 || !/^[0-9a-f]+$/i.test(headSha)) { + throw new Error('Head SHA must be a full 40-character hexadecimal commit SHA.'); + } + + const { data: pull } = await github.rest.pulls.get({ + ...context.repo, + pull_number: Number(prNumber) + }); + const repository = `${context.repo.owner}/${context.repo.repo}`; + if (pull.base.repo.full_name !== repository) { + throw new Error(`PR base repository must be ${repository}.`); + } + const sha = headSha.toLowerCase(); + if (pull.head.sha !== sha) { + throw new Error( + `PR #${prNumber} head changed: expected ${sha}, current ${pull.head.sha}. Review the current head before retrying.` + ); + } + + return { + prNumber: Number(prNumber), + sha, + headRef: pull.head.ref, + prUrl: pull.html_url + }; +} + +module.exports = { resolveBugServerTarget }; diff --git a/.github/scripts/bug-server-dispatch.test.cjs b/.github/scripts/bug-server-dispatch.test.cjs new file mode 100644 index 000000000..fd016e811 --- /dev/null +++ b/.github/scripts/bug-server-dispatch.test.cjs @@ -0,0 +1,84 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { resolveBugServerTarget } = require('./bug-server-dispatch.cjs'); + +const sha = '40be3619d1608aa1d5827f0a465704eeb036a7d3'; + +function fixture(overrides = {}) { + const calls = []; + const context = { + repo: { owner: 'VisActor', repo: 'VRender' }, + ref: 'refs/heads/develop', + payload: { repository: { default_branch: 'develop' } } + }; + const pull = { + base: { repo: { full_name: 'VisActor/VRender' } }, + head: { sha, ref: 'feat/line-render-contribution', repo: { full_name: 'g1f9/VRender' } }, + html_url: 'https://github.com/VisActor/VRender/pull/2128' + }; + const github = { + rest: { + pulls: { + get: async params => { + calls.push(params); + return { data: pull }; + } + } + } + }; + return { args: { github, context, prNumber: '2128', headSha: sha, ...overrides }, calls, pull }; +} + +test('resolves the reviewed fork head, including source metadata', async () => { + const { args, calls } = fixture({ headSha: sha.toUpperCase() }); + assert.deepEqual(await resolveBugServerTarget(args), { + prNumber: 2128, + sha, + headRef: 'feat/line-render-contribution', + prUrl: 'https://github.com/VisActor/VRender/pull/2128' + }); + assert.deepEqual(calls, [{ owner: 'VisActor', repo: 'VRender', pull_number: 2128 }]); +}); + +for (const prNumber of ['', '0', '-1', '1.5', '2128;echo injected', '9007199254740992']) { + test(`rejects invalid PR number ${JSON.stringify(prNumber)} before API access`, async () => { + const { args, calls } = fixture({ prNumber }); + await assert.rejects(resolveBugServerTarget(args), /PR number/); + assert.equal(calls.length, 0); + }); +} + +for (const headSha of ['', '40be3619', 'g'.repeat(40), `${sha}\n`]) { + test(`rejects invalid SHA ${JSON.stringify(headSha)} before API access`, async () => { + const { args, calls } = fixture({ headSha }); + await assert.rejects(resolveBugServerTarget(args), /40-character/); + assert.equal(calls.length, 0); + }); +} + +test('rejects stale approval when the PR has a different head', async () => { + const { args, pull } = fixture(); + pull.head.sha = 'a'.repeat(40); + await assert.rejects(resolveBugServerTarget(args), /head changed/); +}); + +test('rejects a workflow launched from a non-default branch', async () => { + const { args, calls } = fixture(); + args.context.ref = 'refs/heads/feature'; + await assert.rejects(resolveBugServerTarget(args), /default branch/); + assert.equal(calls.length, 0); +}); + +test('rejects a PR belonging to another base repository', async () => { + const { args, pull } = fixture(); + pull.base.repo.full_name = 'someone/VRender'; + await assert.rejects(resolveBugServerTarget(args), /base repository/); +}); + +test('propagates API lookup failures without producing a build target', async () => { + const { args } = fixture(); + args.github.rest.pulls.get = async () => { + throw new Error('Not Found'); + }; + await assert.rejects(resolveBugServerTarget(args), /Not Found/); +}); diff --git a/.github/workflows/bug-server.yml b/.github/workflows/bug-server.yml index 6f2002701..440e14f2a 100644 --- a/.github/workflows/bug-server.yml +++ b/.github/workflows/bug-server.yml @@ -2,6 +2,16 @@ name: Bug Server CI # 这里业务方根据需求设置 on: + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to test (including fork PRs)' + required: true + type: string + head_sha: + description: 'Reviewed PR head commit (full 40-character SHA)' + required: true + type: string push: branches: ['main'] pull_request: @@ -9,10 +19,11 @@ on: jobs: build: + if: github.event_name != 'workflow_dispatch' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js 24.x uses: actions/setup-node@v4 with: @@ -20,6 +31,9 @@ jobs: cache: 'npm' cache-dependency-path: './common/config/rush/pnpm-lock.yaml' + - name: Test manual dispatch validation + run: node --test .github/scripts/bug-server-dispatch.test.cjs + - name: Print All Github Environment Variables run: env @@ -48,3 +62,126 @@ jobs: env: BUG_SERVER_TOKEN: ${{ secrets.BUG_SERVER_TOKEN }} run: node ../../common/scripts/install-run-rushx.js ci + + resolve-manual-target: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + sha: ${{ steps.target.outputs.sha }} + pr_number: ${{ steps.target.outputs.pr_number }} + head_ref: ${{ steps.target.outputs.head_ref }} + pr_url: ${{ steps.target.outputs.pr_url }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + sparse-checkout: .github/scripts + - name: Validate reviewed PR head + id: target + uses: actions/github-script@v8 + env: + PR_NUMBER: ${{ inputs.pr_number }} + HEAD_SHA: ${{ inputs.head_sha }} + with: + script: | + const { resolveBugServerTarget } = require('./.github/scripts/bug-server-dispatch.cjs'); + const target = await resolveBugServerTarget({ + github, context, + prNumber: process.env.PR_NUMBER, + headSha: process.env.HEAD_SHA + }); + core.setOutput('sha', target.sha); + core.setOutput('pr_number', target.prNumber); + core.setOutput('head_ref', target.headRef); + core.setOutput('pr_url', target.prUrl); + await core.summary + .addHeading('Bug Server manual test') + .addLink(`PR #${target.prNumber}`, target.prUrl) + .addRaw(`\n\nTested head: \`${target.sha}\`\n`) + .write(); + + build-manual-bundle: + needs: resolve-manual-target + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + # This runner executes PR code. Do not give it Bug Server secrets or shared caches. + - uses: actions/checkout@v4 + with: + ref: ${{ needs.resolve-manual-target.outputs.sha }} + persist-credentials: false + - name: Verify checkout + env: + EXPECTED_SHA: ${{ needs.resolve-manual-target.outputs.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + - uses: actions/setup-node@v4 + with: + node-version: 24.x + - name: Install native deps for node-canvas + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config libcairo2-dev libpango1.0-dev libpng-dev libjpeg-dev libgif-dev librsvg2-dev + - name: Install and build PR bundle + run: | + node common/scripts/install-run-rush.js update --bypass-policy + node common/scripts/install-run-rush.js install --bypass-policy + node common/scripts/install-run-rush.js build -t @internal/bugserver-trigger + - uses: actions/upload-artifact@v4 + with: + name: bug-server-manual-bundle + path: tools/bugserver-trigger/dist/index.js + if-no-files-found: error + retention-days: 7 + + submit-manual-bundle: + needs: [resolve-manual-target, build-manual-bundle] + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + contents: read + steps: + # Use the immutable default-branch workflow commit, never scripts from the PR. + - uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + sparse-checkout: tools/bugserver-trigger/scripts + - uses: actions/setup-node@v4 + with: + node-version: 24.x + - name: Install isolated trigger client dependencies + run: | + mkdir -p "$RUNNER_TEMP/bug-server-client" + npm install --prefix "$RUNNER_TEMP/bug-server-client" --ignore-scripts --no-audit --no-fund --package-lock=false \ + node-fetch@2.6.6 form-data@4.0.6 ts-node@10.9.0 typescript@4.9.5 + - uses: actions/download-artifact@v4 + with: + name: bug-server-manual-bundle + path: tools/bugserver-trigger/dist + - name: Trigger Bug Server for reviewed PR + working-directory: tools/bugserver-trigger + env: + BUG_SERVER_TOKEN: ${{ secrets.BUG_SERVER_TOKEN }} + NODE_PATH: ${{ runner.temp }}/bug-server-client/node_modules + TEST_SHA: ${{ needs.resolve-manual-target.outputs.sha }} + TEST_REF: refs/pull/${{ needs.resolve-manual-target.outputs.pr_number }}/head + TEST_BRANCH: ${{ needs.resolve-manual-target.outputs.head_ref }} + TEST_PR_URL: ${{ needs.resolve-manual-target.outputs.pr_url }} + run: | + if [ -z "$BUG_SERVER_TOKEN" ]; then + echo '::error::BUG_SERVER_TOKEN is not configured for this repository.' + exit 1 + fi + test -f dist/index.js + printf 'PR: %s\nTested head: `%s`\n' "$TEST_PR_URL" "$TEST_SHA" >> "$GITHUB_STEP_SUMMARY" + env GITHUB_SHA="$TEST_SHA" GITHUB_REF="$TEST_REF" GITHUB_HEAD_REF="$TEST_BRANCH" \ + node "$RUNNER_TEMP/bug-server-client/node_modules/ts-node/dist/bin.js" \ + --transpile-only --skip-project \ + --compiler-options '{"module":"CommonJS","moduleResolution":"node","esModuleInterop":true}' \ + scripts/trigger-test.ts diff --git a/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md b/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md index d8852e5d7..bf9fbe79c 100644 --- a/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md +++ b/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md @@ -19,25 +19,32 @@ Files: `.github/scripts/bug-server-dispatch.cjs`, `.github/scripts/bug-server-dispatch.test.cjs`. -- [ ] Write Node tests for valid fork PRs, invalid PR numbers, malformed SHAs, stale SHAs, wrong base repository and non-default workflow branches. -- [ ] Run `node --test .github/scripts/bug-server-dispatch.test.cjs` and confirm the missing module fails. -- [ ] Implement `resolveBugServerTarget({ github, context, prNumber, headSha })`, returning `{ prNumber, sha, headRef, prUrl }`. Validate locally before calling `github.rest.pulls.get`; compare the returned PR's repository and current head with the requested target. -- [ ] Rerun the tests. +- [x] Write Node tests for valid fork PRs, invalid PR numbers, malformed SHAs, stale SHAs, wrong base repository and non-default workflow branches. +- [x] Run `node --test .github/scripts/bug-server-dispatch.test.cjs` and confirm the missing module fails. +- [x] Implement `resolveBugServerTarget({ github, context, prNumber, headSha })`, returning `{ prNumber, sha, headRef, prUrl }`. Validate locally before calling `github.rest.pulls.get`; compare the returned PR's repository and current head with the requested target. +- [x] Rerun the tests. ## Task 2: Isolate build and submission Files: `.github/workflows/bug-server.yml`. -- [ ] Add the two string inputs, retain existing automatic build behind a non-dispatch condition, and add manual validation/build/submission jobs with read-only repository permissions. -- [ ] Checkout PR code by the validated SHA, verify `git rev-parse HEAD`, build with the existing Rush commands, and upload only the generated bundle. -- [ ] Checkout the trusted client by `github.workflow_sha` in the submission job. Install `node-fetch@2.6.6`, `form-data@4.0.6`, `ts-node@10.9.0`, and `typescript@4.9.5` outside the repository with lifecycle scripts disabled. -- [ ] Run the client through the isolated ts-node executable with explicit CommonJS/esModuleInterop compiler options and reviewed PR metadata; expose the secret only for this command. -- [ ] Run actionlint and simulate the trusted client against mocked API responses, verifying that the bundle is uploaded without execution. +- [x] Add the two string inputs, retain existing automatic build behind a non-dispatch condition, and add manual validation/build/submission jobs with read-only repository permissions. +- [x] Checkout PR code by the validated SHA, verify `git rev-parse HEAD`, build with the existing Rush commands, and upload only the generated bundle. +- [x] Checkout the trusted client by `github.workflow_sha` in the submission job. Install `node-fetch@2.6.6`, `form-data@4.0.6`, `ts-node@10.9.0`, and `typescript@4.9.5` outside the repository with lifecycle scripts disabled. +- [x] Run the client through the isolated ts-node executable with explicit CommonJS/esModuleInterop compiler options and reviewed PR metadata; expose the secret only for this command. +- [x] Run actionlint and simulate the trusted client against mocked API responses, verifying that the bundle is uploaded without execution. ## Task 3: Document and verify Files: `tools/bugserver-trigger/README.md`. -- [ ] Document UI and CLI invocation, default-branch availability, tested head versus merge semantics, metadata, and result logs. -- [ ] Review the final diff for secret exposure, event regressions and shell interpolation; run `git diff --check`. -- [ ] Record completed checks and deliver the local branch. Do not claim a live Bug Server run before the default-branch workflow exists. +- [x] Document UI and CLI invocation, default-branch availability, tested head versus merge semantics, metadata, and result logs. +- [x] Review the final diff for secret exposure, event regressions and shell interpolation; run `git diff --check`. +- [x] Record completed checks and deliver the local branch. Do not claim a live Bug Server run before the default-branch workflow exists. + +## Verification results + +- Node validation tests: 15 passed; also wired into the automatic Bug Server CI job. +- actionlint 1.7.12: passed. Updated the existing checkout v3 to v4 because actionlint rejects its retired runtime. +- Executed the workflow submission shell block in an isolated directory using the exact dependency versions: mock success, photo-test failure and missing-token cases all passed. The mock verified PR metadata and received a bundle that throws if executed; it was only uploaded. +- `git diff --check`: passed. No live Bug Server call was made. diff --git a/tools/bugserver-trigger/README.md b/tools/bugserver-trigger/README.md new file mode 100644 index 000000000..ed14a9ccc --- /dev/null +++ b/tools/bugserver-trigger/README.md @@ -0,0 +1,43 @@ +# Bug Server CI + +`scripts/trigger-test.ts` uploads `dist/index.js`, waits for an SCM build, starts the Bug Server photo tests, and waits for their results. It requires `BUG_SERVER_TOKEN`. + +## Manually test a PR + +After the manual workflow is merged into the repository's default branch (`develop`), maintainers with repository write access can open **Actions → Bug Server CI → Run workflow**. Select **develop**, then enter: + +- `pr_number`: the PR number, including PRs from external forks. +- `head_sha`: the full 40-character SHA of the PR head that you reviewed. + +The equivalent CLI command is: + +```sh +gh workflow run bug-server.yml \ + --repo VisActor/VRender \ + --ref develop \ + -f pr_number=2128 \ + -f head_sha=40be3619d1608aa1d5827f0a465704eeb036a7d3 +``` + +Use the currently reviewed PR head; the example SHA becomes invalid if that PR changes. The workflow rejects non-default workflow branches, malformed inputs and a SHA that differs from the PR's current head. It builds the exact requested **head commit**, not GitHub's generated merge commit. Updates after validation cannot change the commit being built. + +The run appears under Actions; this manual run does not automatically attach a check or comment to the external PR. Its summary records the PR URL and tested head. The **Trigger Bug Server for reviewed PR** step prints `scmVersion`, `bundleId`, and the result counts, which identify the run in Bug Server. A missing token, failed SCM build or failed photo test makes the job fail. + +## Execution boundaries + +The manual workflow uses three separate jobs: + +1. Validate the PR and reviewed SHA using the GitHub API. +2. Build the PR on a runner with read-only repository access, no retained checkout credentials, no shared cache and no Bug Server token. Upload only the generated bundle. +3. On a fresh runner, use the trigger script from the default-branch workflow commit. Install its dependencies separately with npm lifecycle scripts disabled, download the bundle as data, and pass the token only to the API client step. This job never executes the PR bundle or its package scripts. + +Existing push and pull-request automatic runs are unchanged. A fork PR's automatic run still cannot obtain repository secrets; use the manual entry for Bug Server validation. + +## Local validation + +From the repository root: + +```sh +node --test .github/scripts/bug-server-dispatch.test.cjs +actionlint .github/workflows/bug-server.yml +``` From a1d30899caa1257dea859ba456123d9f5e3dc02a Mon Sep 17 00:00:00 2001 From: xile611 Date: Thu, 17 Sep 2026 11:54:27 +0800 Subject: [PATCH 12/18] fix: restrict bug server workflow permissions and cache access --- .../bug-server-cache-policy-check.yml | 17 ++ .github/workflows/bug-server.yml | 9 +- .../2026-09-17-bug-server-security-fix.md | 180 ++++++++++++++++++ 3 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/bug-server-cache-policy-check.yml create mode 100644 docs/superpowers/plans/2026-09-17-bug-server-security-fix.md diff --git a/.github/workflows/bug-server-cache-policy-check.yml b/.github/workflows/bug-server-cache-policy-check.yml new file mode 100644 index 000000000..32f4d1ad0 --- /dev/null +++ b/.github/workflows/bug-server-cache-policy-check.yml @@ -0,0 +1,17 @@ +name: Bug Server cache policy check + +on: + push: + branches: [codex/bugserver-workflow-dispatch] + +permissions: {} + +jobs: + verify: + runs-on: ubuntu-latest + cache-mode: none + steps: + - name: Verify effective cache mode + run: | + printf 'cache mode: %s\n' "${ACTIONS_CACHE_MODE:-unset}" + test "${ACTIONS_CACHE_MODE:-}" = none diff --git a/.github/workflows/bug-server.yml b/.github/workflows/bug-server.yml index 440e14f2a..540639701 100644 --- a/.github/workflows/bug-server.yml +++ b/.github/workflows/bug-server.yml @@ -17,6 +17,9 @@ on: pull_request: branches: ['main', 'develop', 'dev/**'] +permissions: + contents: read + jobs: build: if: github.event_name != 'workflow_dispatch' @@ -108,10 +111,14 @@ jobs: needs: resolve-manual-target runs-on: ubuntu-latest timeout-minutes: 30 + cache-mode: none permissions: contents: read steps: - # This runner executes PR code. Do not give it Bug Server secrets or shared caches. + - name: Verify cache isolation + run: test "${ACTIONS_CACHE_MODE:-}" = none + # PR code runs without Bug Server secrets or cache access. + # cache-mode controls cache tokens independently of GITHUB_TOKEN permissions. - uses: actions/checkout@v4 with: ref: ${{ needs.resolve-manual-target.outputs.sha }} diff --git a/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md b/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md new file mode 100644 index 000000000..1eaa26970 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md @@ -0,0 +1,180 @@ +# Bug Server 手动入口安全修复 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> 当前环境未安装上述执行技能。后续可在当前任务中顺序执行;本次仅制定计划,不修改 workflow、不触发 CI、不更新远端文档。 + +**Goal:** 修复 PR #2134 的默认分支缓存污染风险和自动构建 token 权限过大问题。 + +**Architecture:** 保留现有校验、构建、提交三个手动 jobs。通过 workflow 顶层 `permissions` 限制 GitHub API 权限,通过构建 job 的 `cache-mode` 独立限制缓存权限;这两类权限需要分别控制。 + +**Tech Stack:** GitHub Actions、Node.js 24、GitHub CodeQL、actionlint、GitHub CLI。 + +## Global Constraints + +- 审查基线:PR #2134,head `95514c67ed0c2bee3b1a45641596c168b48a05b2`。 +- 在现有 `codex/bugserver-workflow-dispatch` 分支追加修复,不重建功能或重写已有提交。 +- 保留输入 `pr_number`、`head_sha`、默认分支限制和固定 SHA 构建。 +- 保留独立 runner 和可信提交脚本;`BUG_SERVER_TOKEN` 仍只注入提交 step。 +- 只收紧本 workflow 权限;不修改仓库全局权限设置、发布流程和 Bug Server API。 +- 不用关闭扫描规则、隐藏告警或移除权限限制来让检查变绿。 +- 这是权限配置修复,不添加仅断言 YAML 文本的单测,也不重跑渲染库全量测试。 + +## 已核实的事实与方案选择 + +1. 仓库当前 `default_workflow_permissions` 为 `write`。旧 `build` job 没有显式权限,三个手动 jobs 已有只读权限。 +2. `workflow_dispatch` 在默认分支运行时默认拥有该分支的缓存写权限;不配置 cache action 不会撤销该权限。 +3. GitHub 官方文档支持 job 级别 `cache-mode: none`,由缓存 token 的作用域实施限制;只设置同名环境变量不等价。 +4. 当前最新版 actionlint 1.7.12 对该字段报 `unexpected key "cache-mode"`。已检查 CodeQL 主线的 `CachePoisoningQuery.qll`:其缓存写权限判断仍只看触发事件,没有考虑 `cache-mode`。 + +首选原生权限配置:改动集中,维护者使用方式不变。把 PR 构建搬到 `pull_request` 工作流、手动入口只消费其 artifact 也能隔离缓存,但需要新增运行记录和产物身份校验,不作为本次首选。仅增加 `permissions: contents: read` 无法修复缓存问题。 + +**兼容性处理原则:** GitHub 服务端与 runner 的实际支持需要先验证;不能承诺添加字段后现有 CodeQL 告警必然自动消失。扫描工具的兼容性问题与安全机制是否生效分别记录。 + +## Task 1:收紧两类权限 + +**Files:** +- Modify: `.github/workflows/bug-server.yml` + +**Interfaces:** +- Consumes: 原有事件、输入、job outputs、artifact 名称和提交脚本。 +- Produces: 所有 job 默认只有 `contents: read`;外部 PR 构建 job 没有缓存读写权限。 + +- [ ] **1. 复核执行时的 PR head 和工作区,防止覆盖后续改动。** + +```sh +git status --short +gh pr view 2134 --repo VisActor/VRender --json headRefOid,headRefName,baseRefName +``` + +- [ ] **2. 在 `on` 与 `jobs` 之间增加顶层权限。** + +```yaml +permissions: + contents: read +``` + +保留 `resolve-manual-target` 的 `contents: read`、`pull-requests: read`,以及另外两个手动 jobs 现有的 `contents: read`。旧 `build` 自动继承顶层只读权限;未声明的其他 API 权限不授予。 + +- [ ] **3. 在 `build-manual-bundle` 中增加 job 级缓存限制。** + +```yaml + cache-mode: none +``` + +将原来的缓存注释替换为: + +```yaml + # PR code runs without Bug Server secrets or cache access. + # cache-mode controls cache tokens independently of GITHUB_TOKEN permissions. +``` + +- [ ] **4. 在该 job 的 checkout 之前增加运行时检查。** + +```yaml + - name: Verify cache isolation + run: test "${ACTIONS_CACHE_MODE:-}" = none +``` + +如果 runner 没有报告 `none`,立即停止,不能继续执行 PR 代码。该检查用于确认平台应用配置,实际权限边界仍是 job 级 `cache-mode`,不是环境变量本身。 + +## Task 2:验证平台支持、扫描结果和功能 + +**Files:** +- Test: `.github/scripts/bug-server-dispatch.test.cjs`(已有测试,不修改) +- Temporary: `.github/workflows/bug-server-cache-policy-check.yml`(验收后删除) +- Modify: 本计划的验收记录 + +**Interfaces:** +- Consumes: Task 1 的 workflow 配置。 +- Produces: GitHub 原生解析与运行证据、权限日志、两条扫描告警的处理结果。 + +- [ ] **1. 运行现有校验,记录 actionlint 版本及完整诊断。** + +```sh +node --test .github/scripts/bug-server-dispatch.test.cjs +actionlint -version +actionlint .github/workflows/bug-server.yml +git diff --check +``` + +预期已有 15 项输入校验测试通过。若 actionlint 仍是 1.7.12,明确记录其对新字段的语法误报;不能把这次检查写成通过,也不能泛化忽略所有语法错误。其余诊断均需解决。 + +- [ ] **2. 在 PR 分支运行不包含 PR 代码和 secret 的平台探针。** + +临时文件的完整内容: + +```yaml +name: Bug Server cache policy check +on: + push: + branches: [codex/bugserver-workflow-dispatch] +permissions: {} +jobs: + verify: + runs-on: ubuntu-latest + cache-mode: none + steps: + - name: Verify effective cache mode + run: | + printf 'cache mode: %s\n' "${ACTIONS_CACHE_MODE:-unset}" + test "${ACTIONS_CACHE_MODE:-}" = none +``` + +在修复实现进入正常提交、推送阶段时运行该探针。它不 checkout、不安装依赖、不调用 Bug Server、不读写缓存。验收要求 GitHub 接受 YAML,且日志输出 `cache mode: none`。保留 run URL,再删除临时 workflow。 + +如果 GitHub 拒绝字段或 runner 不报告 `none`,该方案不具备落地条件,应停止合并;不要删除隔离配置继续执行外部 PR。后续改用 `pull_request` 构建 artifact、手动入口校验其来源后上传的方案,并单独完成该架构的实现计划。 + +- [ ] **3. 复查真实自动构建的权限和两条 CodeQL 告警。** + +```sh +gh pr checks 2134 --repo VisActor/VRender +gh api repos/VisActor/VRender/code-scanning/alerts/45 +gh api repos/VisActor/VRender/code-scanning/alerts/46 +``` + +在最新提交的 `build` job 的 Set up job 日志中,确认 `GITHUB_TOKEN Permissions` 没有写权限。确认缺失权限告警已修复;缓存告警若仍存在,核对该次分析使用的规则及提交 SHA。 + +对于尚未识别 `cache-mode` 的 CodeQL,记录官方权限语义、探针 run URL、实际 workflow 配置和规则源码证据。扫描仍失败时,不将 PR 描述成“全部检查通过”,不自动关闭告警;将残留扫描问题明确交付给维护者评审。若仓库合并规则要求该检查通过,解决工具识别问题或改用上述 PR artifact 方案后再合并,不绕过合并规则。 + +- [ ] **4. 合入默认分支后,用已完整审查的可信 PR head 做首次手动验收。** + +通过原有 Run workflow 表单输入该 PR 编号与完整 SHA。检查:校验通过、缓存隔离检查通过、固定 SHA 构建成功、artifact 上传/下载成功、可信脚本成功触发 Bug Server,summary 中 PR/SHA 正确。 + +这一步验证此前自动 PR CI 不会执行的三个手动 jobs。图片差异按 Bug Server 业务结果记录,与权限配置是否生效分别判断。验收失败时暂停手动入口的使用,修复后再为外部 PR 运行;不放宽权限作为回退。 + +## Task 3:同步维护说明并交付 + +**Files:** +- Modify: `tools/bugserver-trigger/README.md` +- Modify: `docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md` +- Append correction: `docs/superpowers/plans/2026-09-17-bug-server-dispatch.md` +- Update after verification: [VRender 日常维护文档](https://bytedance.larkoffice.com/wiki/RNbpwz9HZizi1WkQYcZcqnj6n92) + +**Interfaces:** +- Consumes: Task 2 的真实验证结果。 +- Produces: 与实现一致的权限说明和未完成项记录。 + +- [ ] **1. 用具体权限说明替换含糊的“无共享缓存”。** + +README 的构建边界使用以下说明: + +> Build the reviewed PR with read-only repository access and `cache-mode: none`, which denies cache reads and writes independently of `GITHUB_TOKEN`. Verify the runner reports this mode before checking out PR code. Keep checkout credentials disabled and the Bug Server token on the separate submission runner. + +设计文档和飞书文档说明: + +> 手动构建 job 显式禁止缓存读写,执行 PR 代码前确认该设置生效。GitHub API 权限和缓存权限分别控制;不配置缓存步骤并不等于没有缓存权限。 + +在原实现计划的验证结果后补记:此前单测、mock 和 actionlint 通过仅覆盖功能及旧语法检查,未验证缓存权限隔离;此次修复补齐这项边界。 + +- [ ] **2. 交付时列出改动和真实状态。** + +至少记录最新 commit、两条告警结果、自动构建权限日志、缓存模式探针 URL、首次真实手动运行结果。区分“合并前已验证”和“合并后待验证”,不把计划写成已完成结果。 + +## 参考依据 + +- [GitHub job 级 cache-mode](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idcache-mode) +- [GitHub 缓存权限与事件默认值](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#controlling-cache-access-with-cache-mode) +- [CodeQL 缓存写权限判断源码](https://github.com/github/codeql/blob/main/actions/ql/lib/codeql/actions/security/CachePoisoningQuery.qll) +- [缓存污染告警](https://github.com/VisActor/VRender/pull/2134#discussion_r4032878435) +- [缺失权限告警](https://github.com/VisActor/VRender/pull/2134#discussion_r4032878451) From f8fe1a07af0a7f05b0f2a5649d15e054fae992f2 Mon Sep 17 00:00:00 2001 From: xile611 Date: Thu, 17 Sep 2026 11:56:20 +0800 Subject: [PATCH 13/18] fix: verify cache mode in the JavaScript action environment --- .github/workflows/bug-server-cache-policy-check.yml | 10 +++++++--- .github/workflows/bug-server.yml | 9 ++++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/bug-server-cache-policy-check.yml b/.github/workflows/bug-server-cache-policy-check.yml index 32f4d1ad0..527a9240c 100644 --- a/.github/workflows/bug-server-cache-policy-check.yml +++ b/.github/workflows/bug-server-cache-policy-check.yml @@ -12,6 +12,10 @@ jobs: cache-mode: none steps: - name: Verify effective cache mode - run: | - printf 'cache mode: %s\n' "${ACTIONS_CACHE_MODE:-unset}" - test "${ACTIONS_CACHE_MODE:-}" = none + uses: actions/github-script@v8 + with: + script: | + core.info(`Cache mode: ${process.env.ACTIONS_CACHE_MODE ?? 'unset'}`); + if (process.env.ACTIONS_CACHE_MODE !== 'none') { + core.setFailed('Expected cache-mode: none.'); + } diff --git a/.github/workflows/bug-server.yml b/.github/workflows/bug-server.yml index 540639701..137220b83 100644 --- a/.github/workflows/bug-server.yml +++ b/.github/workflows/bug-server.yml @@ -116,7 +116,14 @@ jobs: contents: read steps: - name: Verify cache isolation - run: test "${ACTIONS_CACHE_MODE:-}" = none + uses: actions/github-script@v8 + with: + script: | + if (process.env.ACTIONS_CACHE_MODE !== 'none') { + core.setFailed('PR builds require cache-mode: none.'); + } else { + core.info('Cache access is disabled for this job.'); + } # PR code runs without Bug Server secrets or cache access. # cache-mode controls cache tokens independently of GITHUB_TOKEN permissions. - uses: actions/checkout@v4 From a3d5f2e6e9844d096f1e5d039632248deeb61f42 Mon Sep 17 00:00:00 2001 From: xile611 Date: Thu, 17 Sep 2026 11:59:27 +0800 Subject: [PATCH 14/18] docs: record bug server security validation and remove probe --- .../bug-server-cache-policy-check.yml | 21 -------- .../plans/2026-09-17-bug-server-dispatch.md | 4 ++ .../2026-09-17-bug-server-security-fix.md | 50 ++++++++++++++----- .../2026-09-17-bug-server-dispatch-design.md | 5 +- tools/bugserver-trigger/README.md | 8 ++- 5 files changed, 52 insertions(+), 36 deletions(-) delete mode 100644 .github/workflows/bug-server-cache-policy-check.yml diff --git a/.github/workflows/bug-server-cache-policy-check.yml b/.github/workflows/bug-server-cache-policy-check.yml deleted file mode 100644 index 527a9240c..000000000 --- a/.github/workflows/bug-server-cache-policy-check.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Bug Server cache policy check - -on: - push: - branches: [codex/bugserver-workflow-dispatch] - -permissions: {} - -jobs: - verify: - runs-on: ubuntu-latest - cache-mode: none - steps: - - name: Verify effective cache mode - uses: actions/github-script@v8 - with: - script: | - core.info(`Cache mode: ${process.env.ACTIONS_CACHE_MODE ?? 'unset'}`); - if (process.env.ACTIONS_CACHE_MODE !== 'none') { - core.setFailed('Expected cache-mode: none.'); - } diff --git a/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md b/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md index bf9fbe79c..32777aad2 100644 --- a/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md +++ b/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md @@ -48,3 +48,7 @@ Files: `tools/bugserver-trigger/README.md`. - actionlint 1.7.12: passed. Updated the existing checkout v3 to v4 because actionlint rejects its retired runtime. - Executed the workflow submission shell block in an isolated directory using the exact dependency versions: mock success, photo-test failure and missing-token cases all passed. The mock verified PR metadata and received a bundle that throws if executed; it was only uploaded. - `git diff --check`: passed. No live Bug Server call was made. + +## Security verification correction (2026-09-17) + +The original tests, mock integration and actionlint run verified functional behavior and workflow syntax, but did not verify cache permissions. Omitting a cache action does not remove the default-branch cache-write capability of a `workflow_dispatch` run. PR #2134 therefore adds explicit workflow-level read-only API permissions and job-level `cache-mode: none`, verified before PR checkout. See the [security fix plan](2026-09-17-bug-server-security-fix.md) for runtime evidence and scanner compatibility limitations. diff --git a/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md b/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md index 1eaa26970..2714f14be 100644 --- a/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md +++ b/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md @@ -2,7 +2,7 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > -> 当前环境未安装上述执行技能。后续可在当前任务中顺序执行;本次仅制定计划,不修改 workflow、不触发 CI、不更新远端文档。 +> 当前环境未安装上述执行技能。用户已授权执行本计划,使用当前任务和仓库工具顺序完成;实际进度与证据记录在文末。 **Goal:** 修复 PR #2134 的默认分支缓存污染风险和自动构建 token 权限过大问题。 @@ -40,14 +40,14 @@ - Consumes: 原有事件、输入、job outputs、artifact 名称和提交脚本。 - Produces: 所有 job 默认只有 `contents: read`;外部 PR 构建 job 没有缓存读写权限。 -- [ ] **1. 复核执行时的 PR head 和工作区,防止覆盖后续改动。** +- [x] **1. 复核执行时的 PR head 和工作区,防止覆盖后续改动。** ```sh git status --short gh pr view 2134 --repo VisActor/VRender --json headRefOid,headRefName,baseRefName ``` -- [ ] **2. 在 `on` 与 `jobs` 之间增加顶层权限。** +- [x] **2. 在 `on` 与 `jobs` 之间增加顶层权限。** ```yaml permissions: @@ -56,7 +56,7 @@ permissions: 保留 `resolve-manual-target` 的 `contents: read`、`pull-requests: read`,以及另外两个手动 jobs 现有的 `contents: read`。旧 `build` 自动继承顶层只读权限;未声明的其他 API 权限不授予。 -- [ ] **3. 在 `build-manual-bundle` 中增加 job 级缓存限制。** +- [x] **3. 在 `build-manual-bundle` 中增加 job 级缓存限制。** ```yaml cache-mode: none @@ -69,15 +69,24 @@ permissions: # cache-mode controls cache tokens independently of GITHUB_TOKEN permissions. ``` -- [ ] **4. 在该 job 的 checkout 之前增加运行时检查。** +- [x] **4. 在该 job 的 checkout 之前增加运行时检查。** ```yaml - name: Verify cache isolation - run: test "${ACTIONS_CACHE_MODE:-}" = none + uses: actions/github-script@v8 + with: + script: | + if (process.env.ACTIONS_CACHE_MODE !== 'none') { + core.setFailed('PR builds require cache-mode: none.'); + } else { + core.info('Cache access is disabled for this job.'); + } ``` 如果 runner 没有报告 `none`,立即停止,不能继续执行 PR 代码。该检查用于确认平台应用配置,实际权限边界仍是 job 级 `cache-mode`,不是环境变量本身。 +执行中修正了检查载体:runner 的 `NodeScriptActionHandler` 会注入 `ACTIONS_CACHE_MODE`,普通 shell step 不会。不能用原计划的 shell 检查把变量未注入误判为平台不支持。 + ## Task 2:验证平台支持、扫描结果和功能 **Files:** @@ -89,7 +98,7 @@ permissions: - Consumes: Task 1 的 workflow 配置。 - Produces: GitHub 原生解析与运行证据、权限日志、两条扫描告警的处理结果。 -- [ ] **1. 运行现有校验,记录 actionlint 版本及完整诊断。** +- [x] **1. 运行现有校验,记录 actionlint 版本及完整诊断。** ```sh node --test .github/scripts/bug-server-dispatch.test.cjs @@ -100,7 +109,7 @@ git diff --check 预期已有 15 项输入校验测试通过。若 actionlint 仍是 1.7.12,明确记录其对新字段的语法误报;不能把这次检查写成通过,也不能泛化忽略所有语法错误。其余诊断均需解决。 -- [ ] **2. 在 PR 分支运行不包含 PR 代码和 secret 的平台探针。** +- [x] **2. 在 PR 分支运行不包含 PR 代码和 secret 的平台探针。** 临时文件的完整内容: @@ -116,9 +125,13 @@ jobs: cache-mode: none steps: - name: Verify effective cache mode - run: | - printf 'cache mode: %s\n' "${ACTIONS_CACHE_MODE:-unset}" - test "${ACTIONS_CACHE_MODE:-}" = none + uses: actions/github-script@v8 + with: + script: | + core.info(`Cache mode: ${process.env.ACTIONS_CACHE_MODE ?? 'unset'}`); + if (process.env.ACTIONS_CACHE_MODE !== 'none') { + core.setFailed('Expected cache-mode: none.'); + } ``` 在修复实现进入正常提交、推送阶段时运行该探针。它不 checkout、不安装依赖、不调用 Bug Server、不读写缓存。验收要求 GitHub 接受 YAML,且日志输出 `cache mode: none`。保留 run URL,再删除临时 workflow。 @@ -155,7 +168,7 @@ gh api repos/VisActor/VRender/code-scanning/alerts/46 - Consumes: Task 2 的真实验证结果。 - Produces: 与实现一致的权限说明和未完成项记录。 -- [ ] **1. 用具体权限说明替换含糊的“无共享缓存”。** +- [x] **1. 用具体权限说明替换含糊的“无共享缓存”。** README 的构建边界使用以下说明: @@ -178,3 +191,16 @@ README 的构建边界使用以下说明: - [CodeQL 缓存写权限判断源码](https://github.com/github/codeql/blob/main/actions/ql/lib/codeql/actions/security/CachePoisoningQuery.qll) - [缓存污染告警](https://github.com/VisActor/VRender/pull/2134#discussion_r4032878435) - [缺失权限告警](https://github.com/VisActor/VRender/pull/2134#discussion_r4032878451) + +## 执行记录(2026-09-17) + +- 权限修复提交:`a1d30899c`;JavaScript action 检查修正:`f8fe1a07a`。 +- [首轮探针](https://github.com/VisActor/VRender/actions/runs/35179960262):GitHub 接受配置,初始化日志为 `Cache mode: none`,但 shell 没有该变量。通过官方 [NodeScriptActionHandler 源码](https://github.com/actions/runner/blob/main/src/Runner.Worker/Handlers/NodeScriptActionHandler.cs) 确认注入边界,改用 JavaScript action 检查。 +- [修正后的平台探针](https://github.com/VisActor/VRender/actions/runs/35180071512):**通过**。runner `2.337.0`;初始化日志及 JavaScript action 均报告 `Cache mode: none`。探针没有执行 PR 代码、接触 Bug Server secret 或读写缓存;验证后删除临时 workflow。 +- Node 输入校验:15/15 通过。直接运行 workflow 中的隔离检查脚本,确认 `none` 放行,`read`、`write`、未注入变量均拒绝,共 4 个场景通过。 +- 推送钩子要求的 `rush test --only tag:package` 已通过;没有以跳过钩子的方式推送。 +- actionlint 1.7.12:仅有 `cache-mode` 未识别诊断,**不记为通过**。GitHub 原生解析和 runner 验证通过。 +- CodeQL 权限告警 #46:实例状态为 **fixed**。缓存告警 #45 在 `f8fe1a07a` 上仍为 **open**;其规则未考虑 `cache-mode`。没有忽略规则或关闭告警。 +- 已查询 develop 的传统 required status checks 和适用 rulesets:前者未启用,后者为空。未修改合并规则,也未合并 PR。 +- 首次真实手动链路仍需在修复合入默认分支后执行;平台探针不等于端到端 Bug Server 验收。 +- README、设计文档、原实现验证记录和飞书维护文档均已同步;飞书文档 revision 17 已回读确认。 diff --git a/docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md b/docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md index 2c766b8d8..5bd3a2563 100644 --- a/docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md +++ b/docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md @@ -14,11 +14,14 @@ 1. 只允许从默认分支运行手动入口。验证 job 从 workflow 的固定 SHA 检出可信校验脚本。 2. 校验 PR 编号、40 位十六进制 SHA,并通过 GitHub API 确认 SHA 等于该 PR 当前 head。错误在构建前终止。 -3. 构建 job 在独立 runner 中检出已验证的固定 SHA,不持有 Bug Server secret、不保留 checkout 凭据、不使用共享缓存。只上传 `tools/bugserver-trigger/dist/index.js`。 +3. 构建 job 使用 `cache-mode: none` 显式禁止缓存读写,并在检出 PR 代码前通过 JavaScript action 确认 runner 报告的模式为 `none`,否则终止。随后在独立 runner 中检出已验证的固定 SHA,不持有 Bug Server secret、不保留 checkout 凭据。只上传 `tools/bugserver-trigger/dist/index.js`。GitHub API 权限和缓存权限分别控制;不配置缓存步骤并不等于没有缓存权限。 4. 上传 job 在新的 runner 中检出 workflow SHA 对应的可信触发脚本。独立安装该脚本所需的固定版本依赖,不执行 PR 的安装脚本或产物。产物仅作为文件上传。 5. 仅调用触发脚本的 step 注入 `BUG_SERVER_TOKEN`。传给现有脚本的提交、PR ref、源分支信息来自验证 job,避免记录成 develop 的提交。 6. Actions summary 记录 PR 与测试 SHA;现有脚本继续输出 `scmVersion`、`bundleId` 和用例结果。 +7. workflow 顶层显式设置 `contents: read`,使原有自动构建也不再继承仓库默认写权限;仅 PR 校验 job 额外需要 `pull-requests: read`。 ## 验证 使用 Node 内置测试覆盖输入校验、默认分支限制、SHA 不匹配以及 fork PR 成功解析;actionlint 校验 workflow。以本地 mock HTTP 模拟上传、SCM 构建和图片测试,验证可信脚本的独立运行及元数据传递。线上触发需要入口合入默认分支后执行。 + +安全修复另行验证 GitHub 原生 `cache-mode` 配置、runner 实际模式和 token 权限。2026-09-17 的 actionlint 1.7.12 与 CodeQL 缓存污染规则尚未完整识别该字段,工具诊断与平台验证结果分别记录,详见 [安全修复计划](../plans/2026-09-17-bug-server-security-fix.md)。 diff --git a/tools/bugserver-trigger/README.md b/tools/bugserver-trigger/README.md index ed14a9ccc..8889e637a 100644 --- a/tools/bugserver-trigger/README.md +++ b/tools/bugserver-trigger/README.md @@ -28,10 +28,12 @@ The run appears under Actions; this manual run does not automatically attach a c The manual workflow uses three separate jobs: 1. Validate the PR and reviewed SHA using the GitHub API. -2. Build the PR on a runner with read-only repository access, no retained checkout credentials, no shared cache and no Bug Server token. Upload only the generated bundle. +2. Build the reviewed PR with read-only repository access and `cache-mode: none`, which denies cache reads and writes independently of `GITHUB_TOKEN`. A JavaScript action verifies the runner reports this mode before checking out PR code. Keep checkout credentials disabled and the Bug Server token on the separate submission runner. Upload only the generated bundle. 3. On a fresh runner, use the trigger script from the default-branch workflow commit. Install its dependencies separately with npm lifecycle scripts disabled, download the bundle as data, and pass the token only to the API client step. This job never executes the PR bundle or its package scripts. -Existing push and pull-request automatic runs are unchanged. A fork PR's automatic run still cannot obtain repository secrets; use the manual entry for Bug Server validation. +The workflow defaults to `contents: read`; only the PR validation job additionally requests `pull-requests: read`. Existing push and pull-request automatic runs retain their build and test steps with read-only repository permissions. A fork PR's automatic run still cannot obtain repository secrets; use the manual entry for Bug Server validation. + +If **Verify cache isolation** fails, stop and check the runner's cache-mode support. Do not set `ACTIONS_CACHE_MODE` yourself or remove the guard: the job-level `cache-mode` setting must restrict the cache token. The runner exposes the mode to JavaScript actions, not ordinary shell steps. ## Local validation @@ -41,3 +43,5 @@ From the repository root: node --test .github/scripts/bug-server-dispatch.test.cjs actionlint .github/workflows/bug-server.yml ``` + +As of 2026-09-17, actionlint 1.7.12 does not recognize `cache-mode`, and CodeQL's cache-poisoning rule does not account for it. Record these diagnostics separately from the GitHub runtime verification; do not describe them as passing or disable the security rule. See the [security fix plan](../../docs/superpowers/plans/2026-09-17-bug-server-security-fix.md) for evidence and remaining validation. From 46ad9c3f4a5f8556df3ee04c4827cdb2c3839094 Mon Sep 17 00:00:00 2001 From: xile611 Date: Thu, 17 Sep 2026 12:02:29 +0800 Subject: [PATCH 15/18] docs: record verified automatic build permissions --- .../plans/2026-09-17-bug-server-security-fix.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md b/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md index 2714f14be..21415aebd 100644 --- a/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md +++ b/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md @@ -138,7 +138,7 @@ jobs: 如果 GitHub 拒绝字段或 runner 不报告 `none`,该方案不具备落地条件,应停止合并;不要删除隔离配置继续执行外部 PR。后续改用 `pull_request` 构建 artifact、手动入口校验其来源后上传的方案,并单独完成该架构的实现计划。 -- [ ] **3. 复查真实自动构建的权限和两条 CodeQL 告警。** +- [x] **3. 复查真实自动构建的权限和两条 CodeQL 告警。** ```sh gh pr checks 2134 --repo VisActor/VRender @@ -180,7 +180,7 @@ README 的构建边界使用以下说明: 在原实现计划的验证结果后补记:此前单测、mock 和 actionlint 通过仅覆盖功能及旧语法检查,未验证缓存权限隔离;此次修复补齐这项边界。 -- [ ] **2. 交付时列出改动和真实状态。** +- [x] **2. 交付时列出改动和真实状态。** 至少记录最新 commit、两条告警结果、自动构建权限日志、缓存模式探针 URL、首次真实手动运行结果。区分“合并前已验证”和“合并后待验证”,不把计划写成已完成结果。 @@ -200,7 +200,8 @@ README 的构建边界使用以下说明: - Node 输入校验:15/15 通过。直接运行 workflow 中的隔离检查脚本,确认 `none` 放行,`read`、`write`、未注入变量均拒绝,共 4 个场景通过。 - 推送钩子要求的 `rush test --only tag:package` 已通过;没有以跳过钩子的方式推送。 - actionlint 1.7.12:仅有 `cache-mode` 未识别诊断,**不记为通过**。GitHub 原生解析和 runner 验证通过。 -- CodeQL 权限告警 #46:实例状态为 **fixed**。缓存告警 #45 在 `f8fe1a07a` 上仍为 **open**;其规则未考虑 `cache-mode`。没有忽略规则或关闭告警。 +- CodeQL 权限告警 #46:实例状态为 **fixed**。缓存告警 #45 在 `a3d5f2e6e` 上仍为 **open**;其规则未考虑 `cache-mode`。没有忽略规则或关闭告警。 +- [自动构建启动日志](https://github.com/VisActor/VRender/actions/runs/35179962932/job/105069769361):`GITHUB_TOKEN Permissions` 仅有 `Contents: read`、`Metadata: read`,没有写权限。该 run 的构建步骤通过;后因新提交替代而取消,与另一旧提交的重复 CI 一同清理,最新提交的 CI 继续运行。 - 已查询 develop 的传统 required status checks 和适用 rulesets:前者未启用,后者为空。未修改合并规则,也未合并 PR。 - 首次真实手动链路仍需在修复合入默认分支后执行;平台探针不等于端到端 Bug Server 验收。 - README、设计文档、原实现验证记录和飞书维护文档均已同步;飞书文档 revision 17 已回读确认。 From ae7fc0926581218905a90a3838bfb5d65741f128 Mon Sep 17 00:00:00 2001 From: xile611 Date: Thu, 17 Sep 2026 14:04:29 +0800 Subject: [PATCH 16/18] fix: build Bug Server PR artifacts outside the default branch context --- .github/scripts/bug-server-dispatch.cjs | 64 +++++- .github/scripts/bug-server-dispatch.test.cjs | 209 +++++++++++++++++- .github/scripts/extract_bug_server_bundle.py | 33 +++ .../scripts/test_extract_bug_server_bundle.py | 78 +++++++ .github/workflows/bug-server-pr-bundle.yml | 42 ++++ .github/workflows/bug-server.yml | 79 +++---- .../plans/2026-09-17-bug-server-dispatch.md | 2 + .../2026-09-17-bug-server-pr-artifact.md | 50 +++++ .../2026-09-17-bug-server-security-fix.md | 2 + .../2026-09-17-bug-server-dispatch-design.md | 30 ++- tools/bugserver-trigger/README.md | 23 +- 11 files changed, 527 insertions(+), 85 deletions(-) create mode 100644 .github/scripts/extract_bug_server_bundle.py create mode 100644 .github/scripts/test_extract_bug_server_bundle.py create mode 100644 .github/workflows/bug-server-pr-bundle.yml create mode 100644 docs/superpowers/plans/2026-09-17-bug-server-pr-artifact.md diff --git a/.github/scripts/bug-server-dispatch.cjs b/.github/scripts/bug-server-dispatch.cjs index 401828e2b..815be0bec 100644 --- a/.github/scripts/bug-server-dispatch.cjs +++ b/.github/scripts/bug-server-dispatch.cjs @@ -25,11 +25,73 @@ async function resolveBugServerTarget({ github, context, prNumber, headSha }) { ); } + if (!pull.head.repo) { + throw new Error('The PR head repository no longer exists.'); + } + const workflowPath = '.github/workflows/bug-server-pr-bundle.yml'; + const { data: workflow } = await github.rest.actions.getWorkflow({ + ...context.repo, + workflow_id: 'bug-server-pr-bundle.yml' + }); + const runs = await github.paginate(github.rest.actions.listWorkflowRuns, { + ...context.repo, + workflow_id: workflow.id, + event: 'pull_request', + head_sha: sha, + status: 'success', + per_page: 100 + }); + const run = runs + .filter( + candidate => + candidate.workflow_id === workflow.id && + candidate.path === workflowPath && + candidate.event === 'pull_request' && + candidate.status === 'completed' && + candidate.conclusion === 'success' && + candidate.head_sha === sha && + candidate.head_branch === pull.head.ref && + candidate.repository?.id === pull.base.repo.id && + candidate.head_repository?.id === pull.head.repo.id && + // GitHub omits PR associations for fork runs. Repository, branch and SHA still bind the source. + (!candidate.pull_requests?.length || candidate.pull_requests.some(pr => pr.number === Number(prNumber))) + ) + .sort((a, b) => b.id - a.id)[0]; + if (!run) { + throw new Error( + `No successful PR bundle build for PR #${prNumber} at ${sha}. Wait for or re-run Bug Server PR Bundle.` + ); + } + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + ...context.repo, + run_id: run.id, + per_page: 100 + }); + const matches = artifacts.filter(artifact => artifact.name === `bug-server-pr-${prNumber}-${sha}`); + if (matches.length !== 1) { + throw new Error(`Expected exactly one PR bundle artifact in run ${run.id}. Re-run Bug Server PR Bundle.`); + } + const artifact = matches[0]; + if (artifact.expired) { + throw new Error('The PR bundle artifact has expired. Re-run Bug Server PR Bundle.'); + } + if ( + artifact.workflow_run?.id !== run.id || + artifact.workflow_run.head_sha !== sha || + artifact.workflow_run.repository_id !== pull.base.repo.id || + artifact.workflow_run.head_repository_id !== pull.head.repo.id + ) { + throw new Error('Artifact provenance does not match the reviewed PR build.'); + } + return { prNumber: Number(prNumber), sha, headRef: pull.head.ref, - prUrl: pull.html_url + prUrl: pull.html_url, + runId: run.id, + runUrl: run.html_url, + artifactId: artifact.id }; } diff --git a/.github/scripts/bug-server-dispatch.test.cjs b/.github/scripts/bug-server-dispatch.test.cjs index fd016e811..4a3e6473a 100644 --- a/.github/scripts/bug-server-dispatch.test.cjs +++ b/.github/scripts/bug-server-dispatch.test.cjs @@ -12,12 +12,55 @@ function fixture(overrides = {}) { payload: { repository: { default_branch: 'develop' } } }; const pull = { - base: { repo: { full_name: 'VisActor/VRender' } }, - head: { sha, ref: 'feat/line-render-contribution', repo: { full_name: 'g1f9/VRender' } }, + base: { repo: { id: 1, full_name: 'VisActor/VRender' } }, + head: { sha, ref: 'feat/line-render-contribution', repo: { id: 2, full_name: 'g1f9/VRender' } }, html_url: 'https://github.com/VisActor/VRender/pull/2128' }; + const run = { + id: 100, + workflow_id: 50, + path: '.github/workflows/bug-server-pr-bundle.yml', + event: 'pull_request', + status: 'completed', + conclusion: 'success', + head_sha: sha, + head_branch: pull.head.ref, + repository: { id: 1 }, + head_repository: { id: 2 }, + pull_requests: [], + html_url: 'https://github.com/VisActor/VRender/actions/runs/100' + }; + const artifact = { + id: 200, + name: `bug-server-pr-2128-${sha}`, + expired: false, + workflow_run: { id: 100, repository_id: 1, head_repository_id: 2, head_sha: sha } + }; + const runs = [run]; + const artifacts = [artifact]; const github = { + paginate: async (method, params) => { + const { data } = await method(params); + return data.workflow_runs ?? data.artifacts; + }, rest: { + actions: { + getWorkflow: async params => { + assert.equal(params.workflow_id, 'bug-server-pr-bundle.yml'); + return { data: { id: 50, path: '.github/workflows/bug-server-pr-bundle.yml' } }; + }, + listWorkflowRuns: async params => { + assert.equal(params.workflow_id, 50); + assert.equal(params.head_sha, sha); + assert.equal(params.event, 'pull_request'); + assert.equal(params.status, 'success'); + return { data: { workflow_runs: runs } }; + }, + listWorkflowRunArtifacts: async params => { + assert.equal(params.run_id, 100); + return { data: { artifacts } }; + } + }, pulls: { get: async params => { calls.push(params); @@ -26,7 +69,15 @@ function fixture(overrides = {}) { } } }; - return { args: { github, context, prNumber: '2128', headSha: sha, ...overrides }, calls, pull }; + return { + args: { github, context, prNumber: '2128', headSha: sha, ...overrides }, + calls, + pull, + run, + artifact, + runs, + artifacts + }; } test('resolves the reviewed fork head, including source metadata', async () => { @@ -35,7 +86,10 @@ test('resolves the reviewed fork head, including source metadata', async () => { prNumber: 2128, sha, headRef: 'feat/line-render-contribution', - prUrl: 'https://github.com/VisActor/VRender/pull/2128' + prUrl: 'https://github.com/VisActor/VRender/pull/2128', + runId: 100, + runUrl: 'https://github.com/VisActor/VRender/actions/runs/100', + artifactId: 200 }); assert.deepEqual(calls, [{ owner: 'VisActor', repo: 'VRender', pull_number: 2128 }]); }); @@ -82,3 +136,150 @@ test('propagates API lookup failures without producing a build target', async () }; await assert.rejects(resolveBugServerTarget(args), /Not Found/); }); + +for (const [name, change] of [ + [ + 'wrong workflow', + run => { + run.workflow_id = 51; + } + ], + [ + 'wrong workflow path', + run => { + run.path = '.github/workflows/other.yml'; + } + ], + [ + 'wrong event', + run => { + run.event = 'workflow_dispatch'; + } + ], + [ + 'wrong run SHA', + run => { + run.head_sha = 'a'.repeat(40); + } + ], + [ + 'wrong base repository', + run => { + run.repository.id = 3; + } + ], + [ + 'wrong head repository', + run => { + run.head_repository.id = 3; + } + ], + [ + 'wrong source branch', + run => { + run.head_branch = 'another-branch'; + } + ], + [ + 'wrong PR association', + run => { + run.pull_requests = [{ number: 2135 }]; + } + ], + [ + 'failed build', + run => { + run.conclusion = 'failure'; + } + ], + [ + 'unfinished build', + run => { + run.status = 'in_progress'; + } + ] +]) { + test(`rejects artifact source with ${name}`, async () => { + const { args, run } = fixture(); + change(run); + await assert.rejects(resolveBugServerTarget(args), /No successful PR bundle build/); + }); +} + +test('accepts an explicit matching PR association', async () => { + const { args, run } = fixture(); + run.pull_requests = [{ number: 2128 }]; + assert.equal((await resolveBugServerTarget(args)).artifactId, 200); +}); + +test('rejects missing workflow runs', async () => { + const { args, runs } = fixture(); + runs.length = 0; + await assert.rejects(resolveBugServerTarget(args), /No successful PR bundle build/); +}); + +test('does not select an older run instead of the latest matching run', async () => { + const { args, run, runs } = fixture(); + runs.unshift({ ...run, id: 99 }); + assert.equal((await resolveBugServerTarget(args)).runId, 100); +}); + +for (const [name, change] of [ + [ + 'expired', + artifact => { + artifact.expired = true; + } + ], + [ + 'wrong run', + artifact => { + artifact.workflow_run.id = 101; + } + ], + [ + 'wrong head SHA', + artifact => { + artifact.workflow_run.head_sha = 'a'.repeat(40); + } + ], + [ + 'wrong base repository', + artifact => { + artifact.workflow_run.repository_id = 3; + } + ], + [ + 'wrong head repository', + artifact => { + artifact.workflow_run.head_repository_id = 3; + } + ] +]) { + test(`rejects ${name} artifact`, async () => { + const { args, artifact } = fixture(); + change(artifact); + await assert.rejects(resolveBugServerTarget(args), /Artifact provenance|expired/); + }); +} + +test('rejects an artifact from a different PR or SHA', async () => { + const { args, artifact } = fixture(); + artifact.name = `bug-server-pr-2135-${sha}`; + await assert.rejects(resolveBugServerTarget(args), /exactly one/); +}); + +test('rejects missing or ambiguous artifacts', async () => { + for (const count of [0, 2]) { + const { args, artifacts, artifact } = fixture(); + artifacts.splice(0, 1, ...Array(count).fill(artifact)); + await assert.rejects(resolveBugServerTarget(args), /exactly one/); + } +}); + +test('does not silently fall back when the newest run artifact has expired', async () => { + const { args, run, runs, artifact } = fixture(); + runs.push({ ...run, id: 99 }); + artifact.expired = true; + await assert.rejects(resolveBugServerTarget(args), /expired/); +}); diff --git a/.github/scripts/extract_bug_server_bundle.py b/.github/scripts/extract_bug_server_bundle.py new file mode 100644 index 000000000..43b51a7de --- /dev/null +++ b/.github/scripts/extract_bug_server_bundle.py @@ -0,0 +1,33 @@ +"""Read a PR artifact as data without trusting archive paths or file attributes.""" + +import stat +import sys +import zipfile +from pathlib import Path + +MAX_BUNDLE_BYTES = 64 * 1024 * 1024 + + +def extract_bundle(archive_path, destination): + with zipfile.ZipFile(archive_path) as archive: + entries = archive.infolist() + if len(entries) != 1 or entries[0].filename != 'index.js': + raise ValueError('The PR artifact must contain exactly one file named index.js.') + entry = entries[0] + file_type = stat.S_IFMT(entry.external_attr >> 16) + if entry.is_dir() or file_type not in (0, stat.S_IFREG): + raise ValueError('The PR bundle must be a regular file, not a link or directory.') + if entry.file_size > MAX_BUNDLE_BYTES: + raise ValueError('The PR bundle exceeds the 64 MiB limit.') + with archive.open(entry) as source: + data = source.read(MAX_BUNDLE_BYTES + 1) + if len(data) > MAX_BUNDLE_BYTES: + raise ValueError('The PR bundle exceeds the 64 MiB limit.') + destination = Path(destination) + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open('xb') as output: + output.write(data) + + +if __name__ == '__main__': + extract_bundle(sys.argv[1], sys.argv[2]) diff --git a/.github/scripts/test_extract_bug_server_bundle.py b/.github/scripts/test_extract_bug_server_bundle.py new file mode 100644 index 000000000..893af7068 --- /dev/null +++ b/.github/scripts/test_extract_bug_server_bundle.py @@ -0,0 +1,78 @@ +import stat +import tempfile +import unittest +import warnings +import zipfile +from pathlib import Path +from unittest.mock import patch + +from extract_bug_server_bundle import extract_bundle + + +class ExtractBundleTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.archive = self.root / 'bundle.zip' + self.destination = self.root / 'dist' / 'index.js' + + def archive_entries(self, entries): + with warnings.catch_warnings(): + warnings.simplefilter('ignore', UserWarning) + with zipfile.ZipFile(self.archive, 'w', zipfile.ZIP_DEFLATED) as archive: + for name, data in entries: + archive.writestr(name, data) + + def test_preserves_binary_content(self): + data = b'\x00\xffbundle\n' + self.archive_entries([('index.js', data)]) + extract_bundle(self.archive, self.destination) + self.assertEqual(self.destination.read_bytes(), data) + + def test_executable_text_is_only_data(self): + data = b'throw new Error("BUNDLE_MUST_NOT_EXECUTE");' + self.archive_entries([('index.js', data)]) + extract_bundle(self.archive, self.destination) + self.assertEqual(self.destination.read_bytes(), data) + + def test_rejects_unexpected_names_and_extra_files(self): + for entries in [ + [], [('index.js', b'a'), ('scripts/trigger-test.ts', b'evil')], + [('../scripts/trigger-test.ts', b'evil')], [('/tmp/index.js', b'evil')], + [('index.js', b'a'), ('index.js', b'b')], [('folder/index.js', b'a')], + ]: + with self.subTest(entries=entries): + self.archive_entries(entries) + with self.assertRaises(ValueError): + extract_bundle(self.archive, self.destination) + self.assertFalse(self.destination.exists()) + + def test_rejects_links_and_special_files(self): + for file_type in [stat.S_IFLNK, stat.S_IFDIR, stat.S_IFIFO, stat.S_IFCHR]: + with self.subTest(file_type=file_type): + entry = zipfile.ZipInfo('index.js') + entry.create_system = 3 + entry.external_attr = (file_type | 0o777) << 16 + self.archive_entries([(entry, b'../scripts/trigger-test.ts')]) + with self.assertRaises(ValueError): + extract_bundle(self.archive, self.destination) + + def test_rejects_oversized_bundle(self): + self.archive_entries([('index.js', b'x' * 1025)]) + with patch('extract_bug_server_bundle.MAX_BUNDLE_BYTES', 1024): + with self.assertRaises(ValueError): + extract_bundle(self.archive, self.destination) + self.assertFalse(self.destination.exists()) + + def test_does_not_overwrite_existing_file(self): + self.archive_entries([('index.js', b'new')]) + self.destination.parent.mkdir() + self.destination.write_bytes(b'original') + with self.assertRaises(FileExistsError): + extract_bundle(self.archive, self.destination) + self.assertEqual(self.destination.read_bytes(), b'original') + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/bug-server-pr-bundle.yml b/.github/workflows/bug-server-pr-bundle.yml new file mode 100644 index 000000000..fa86e3e01 --- /dev/null +++ b/.github/workflows/bug-server-pr-bundle.yml @@ -0,0 +1,42 @@ +name: Bug Server PR Bundle + +on: + pull_request: + branches: ['main', 'develop', 'dev/**'] + +permissions: + contents: read + +jobs: + build-pr-bundle: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + # PR code runs only in the pull_request context; cache writes stay scoped to the PR. + # This workflow never receives the Bug Server token. + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - name: Verify checkout + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + - uses: actions/setup-node@v4 + with: + node-version: 24.x + - name: Install native deps for node-canvas + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config libcairo2-dev libpango1.0-dev libpng-dev libjpeg-dev libgif-dev librsvg2-dev + - name: Install and build PR bundle + run: | + node common/scripts/install-run-rush.js update --bypass-policy + node common/scripts/install-run-rush.js install --bypass-policy + node common/scripts/install-run-rush.js build -t @internal/bugserver-trigger + - uses: actions/upload-artifact@v4 + with: + name: bug-server-pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + path: tools/bugserver-trigger/dist/index.js + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/bug-server.yml b/.github/workflows/bug-server.yml index 137220b83..384fa2d81 100644 --- a/.github/workflows/bug-server.yml +++ b/.github/workflows/bug-server.yml @@ -35,7 +35,9 @@ jobs: cache-dependency-path: './common/config/rush/pnpm-lock.yaml' - name: Test manual dispatch validation - run: node --test .github/scripts/bug-server-dispatch.test.cjs + run: | + node --test .github/scripts/bug-server-dispatch.test.cjs + python3 -m unittest discover -s .github/scripts -p 'test_extract_bug_server_bundle.py' - name: Print All Github Environment Variables run: env @@ -72,11 +74,13 @@ jobs: permissions: contents: read pull-requests: read + actions: read outputs: sha: ${{ steps.target.outputs.sha }} pr_number: ${{ steps.target.outputs.pr_number }} head_ref: ${{ steps.target.outputs.head_ref }} pr_url: ${{ steps.target.outputs.pr_url }} + artifact_id: ${{ steps.target.outputs.artifact_id }} steps: - uses: actions/checkout@v4 with: @@ -101,71 +105,30 @@ jobs: core.setOutput('pr_number', target.prNumber); core.setOutput('head_ref', target.headRef); core.setOutput('pr_url', target.prUrl); + core.setOutput('artifact_id', target.artifactId); await core.summary .addHeading('Bug Server manual test') .addLink(`PR #${target.prNumber}`, target.prUrl) + .addLink(`Source build ${target.runId}`, target.runUrl) .addRaw(`\n\nTested head: \`${target.sha}\`\n`) .write(); - build-manual-bundle: - needs: resolve-manual-target - runs-on: ubuntu-latest - timeout-minutes: 30 - cache-mode: none - permissions: - contents: read - steps: - - name: Verify cache isolation - uses: actions/github-script@v8 - with: - script: | - if (process.env.ACTIONS_CACHE_MODE !== 'none') { - core.setFailed('PR builds require cache-mode: none.'); - } else { - core.info('Cache access is disabled for this job.'); - } - # PR code runs without Bug Server secrets or cache access. - # cache-mode controls cache tokens independently of GITHUB_TOKEN permissions. - - uses: actions/checkout@v4 - with: - ref: ${{ needs.resolve-manual-target.outputs.sha }} - persist-credentials: false - - name: Verify checkout - env: - EXPECTED_SHA: ${{ needs.resolve-manual-target.outputs.sha }} - run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - - uses: actions/setup-node@v4 - with: - node-version: 24.x - - name: Install native deps for node-canvas - run: | - sudo apt-get update - sudo apt-get install -y build-essential pkg-config libcairo2-dev libpango1.0-dev libpng-dev libjpeg-dev libgif-dev librsvg2-dev - - name: Install and build PR bundle - run: | - node common/scripts/install-run-rush.js update --bypass-policy - node common/scripts/install-run-rush.js install --bypass-policy - node common/scripts/install-run-rush.js build -t @internal/bugserver-trigger - - uses: actions/upload-artifact@v4 - with: - name: bug-server-manual-bundle - path: tools/bugserver-trigger/dist/index.js - if-no-files-found: error - retention-days: 7 - submit-manual-bundle: - needs: [resolve-manual-target, build-manual-bundle] + needs: resolve-manual-target runs-on: ubuntu-latest timeout-minutes: 120 permissions: contents: read + actions: read steps: # Use the immutable default-branch workflow commit, never scripts from the PR. - uses: actions/checkout@v4 with: ref: ${{ github.workflow_sha }} persist-credentials: false - sparse-checkout: tools/bugserver-trigger/scripts + sparse-checkout: | + .github/scripts + tools/bugserver-trigger/scripts - uses: actions/setup-node@v4 with: node-version: 24.x @@ -174,10 +137,22 @@ jobs: mkdir -p "$RUNNER_TEMP/bug-server-client" npm install --prefix "$RUNNER_TEMP/bug-server-client" --ignore-scripts --no-audit --no-fund --package-lock=false \ node-fetch@2.6.6 form-data@4.0.6 ts-node@10.9.0 typescript@4.9.5 - - uses: actions/download-artifact@v4 + - name: Download reviewed PR artifact + uses: actions/github-script@v8 + env: + ARTIFACT_ID: ${{ needs.resolve-manual-target.outputs.artifact_id }} with: - name: bug-server-manual-bundle - path: tools/bugserver-trigger/dist + script: | + const archive = await github.rest.actions.downloadArtifact({ + ...context.repo, + artifact_id: Number(process.env.ARTIFACT_ID), + archive_format: 'zip' + }); + const fs = require('node:fs'); + const path = require('node:path'); + fs.writeFileSync(path.join(process.env.RUNNER_TEMP, 'bug-server-bundle.zip'), Buffer.from(archive.data)); + - name: Read bundle as data + run: python3 .github/scripts/extract_bug_server_bundle.py "$RUNNER_TEMP/bug-server-bundle.zip" tools/bugserver-trigger/dist/index.js - name: Trigger Bug Server for reviewed PR working-directory: tools/bugserver-trigger env: diff --git a/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md b/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md index 32777aad2..ced96a8ce 100644 --- a/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md +++ b/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md @@ -1,5 +1,7 @@ # Bug Server Manual Dispatch Implementation Plan +> Historical implementation record. The default-branch build described below has been replaced by the [PR artifact flow](2026-09-17-bug-server-pr-artifact.md); use the current README and design for maintenance. + > Execute inline in this task; the workflow design was approved in the conversation. The referenced superpowers execution skills are not installed, so implementation uses the available repository tools. **Goal:** Allow maintainers to test an external PR at a reviewed SHA without creating a temporary PR. diff --git a/docs/superpowers/plans/2026-09-17-bug-server-pr-artifact.md b/docs/superpowers/plans/2026-09-17-bug-server-pr-artifact.md new file mode 100644 index 000000000..e66f8fe06 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-bug-server-pr-artifact.md @@ -0,0 +1,50 @@ +# Bug Server PR Artifact Implementation Plan + +> Execute inline as the fallback in the approved security fix plan. The user reported that alert #45 still blocks the latest revision; this plan completes that repair without dismissing the alert. + +**Goal:** Remove PR code execution from the default-branch manual workflow and make the CodeQL security check pass. + +**Architecture:** A separate `pull_request` workflow builds the exact head with read-only repository permissions. The manual workflow retains PR number and reviewed SHA inputs, locates an immutable artifact using GitHub API provenance, and submits only its `index.js` bytes with the trusted client. No PR build scripts or downloaded code execute in the manual workflow. + +**Tech Stack:** GitHub Actions, Node.js tests, Python standard-library ZIP handling. + +## Constraints + +- Keep existing push / PR Bug Server behavior and the trusted client API protocol. +- Keep workflow-wide `contents: read`; grant `actions: read` only to manual artifact lookup/download jobs and `pull-requests: read` only to target validation. +- Remove the superseded `build-manual-bundle`, `cache-mode` and runtime-mode guard; isolation comes from the PR event's cache scope. +- Treat artifacts as untrusted bytes. Never extract archive paths or execute their content. +- Fork run API responses can have an empty `pull_requests` array: verified using PR #2128 run 35075478495. Bind provenance to workflow ID/path, event, repository IDs, source branch and exact run head SHA. If PR associations are present, they must include the requested PR. + +## Task 1: Resolve a PR artifact + +Files: `.github/scripts/bug-server-dispatch.cjs`, `.github/scripts/bug-server-dispatch.test.cjs`. + +- [x] Extend fixture tests to cover successful fork provenance, incorrect workflow/event/SHA/repository/branch/PR, unsuccessful builds, and missing/expired/ambiguous artifacts. Preserve all input validation tests. +- [x] Run `node --test .github/scripts/bug-server-dispatch.test.cjs`; confirm new tests fail before implementation. +- [x] Extend `resolveBugServerTarget({github, context, prNumber, headSha})` to return the existing target fields plus `{runId, runUrl, artifactId}`. Resolve `bug-server-pr-bundle.yml`, list successful PR runs for the reviewed SHA, select the latest matching run, and select exactly one non-expired artifact named `bug-server-pr-${prNumber}-${sha}` whose API provenance matches the run. +- [x] Run the tests again; all provenance rejection cases must pass. + +## Task 2: Move the build and safely consume the artifact + +Files: `.github/workflows/bug-server-pr-bundle.yml`, `.github/workflows/bug-server.yml`, `.github/scripts/extract_bug_server_bundle.py`, `.github/scripts/test_extract_bug_server_bundle.py`. + +- [x] Add a PR-only workflow for `main`, `develop`, `dev/**`, using checkout at `github.event.pull_request.head.sha`, disabled persisted credentials, Node 24, the existing native dependencies/Rush build and upload-artifact v4. Artifact retention: 7 days. No repository secrets or cache action. +- [x] Add ZIP tests for a valid binary bundle, executable text treated as bytes, path traversal, extra files, duplicate names, symlink entries, oversized payloads and existing output files. +- [x] Implement `extract_bundle(archive_path, destination)` with Python `zipfile`: require exactly one regular entry named `index.js`, limit the uncompressed bundle to 64 MiB, and write bytes to the explicit destination with exclusive creation. Do not call `extract` or `extractall`. +- [x] Remove the manual build job. Add trusted API artifact lookup outputs and download the selected artifact ID into a fixed temporary ZIP file. Run the trusted extraction script before the token-bearing submission step; keep the existing client command unchanged. +- [x] Run `python3 -m unittest discover -s .github/scripts -p 'test_extract_bug_server_bundle.py'`, the Node tests, actionlint on both workflows and `git diff --check`. + +## Task 3: Verify and document + +- [ ] Push the update to PR #2134, check CodeQL alert #45 and #46 on the new commit, and require the CodeQL check to pass without dismissals. +- [ ] Wait for the new PR-only bundle workflow to succeed. Invoke the trusted resolver against that real run, download its immutable artifact, verify single-file extraction, and verify the existing upload client with the local mock API. Do not execute the bundle. +- [ ] Update README, design, the previous security plan, PR description and the existing Lark maintenance section. Document that maintainers wait for `Bug Server PR Bundle` before dispatch; missing/expired artifacts require a fresh successful PR bundle run. Existing PRs may need a new PR event after the workflow is merged. +- [ ] Record separate results for security checks, artifact pipeline and the existing photo CI. End-to-end manual dispatch from the default branch remains a post-merge check. + +## Validation before push + +- Node resolver tests: 36 passed. +- Python archive tests: 6 tests passed, including multiple malicious-entry subcases. +- actionlint 1.7.12: both final workflows pass without ignored diagnostics. +- `git diff --check`: passed. diff --git a/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md b/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md index 21415aebd..a9aa46155 100644 --- a/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md +++ b/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md @@ -1,5 +1,7 @@ # Bug Server 手动入口安全修复 Implementation Plan +> **已被替代:** 本文保留第一轮 `cache-mode` 修复及验证记录。该方案未消除最新 CodeQL 告警,不再作为最终实现;当前方案见 [PR artifact 修复计划](2026-09-17-bug-server-pr-artifact.md)。默认分支手动流程现改为只消费 PR 工作流的产物,不执行 PR 构建代码。 + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > > 当前环境未安装上述执行技能。用户已授权执行本计划,使用当前任务和仓库工具顺序完成;实际进度与证据记录在文末。 diff --git a/docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md b/docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md index 5bd3a2563..603c95698 100644 --- a/docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md +++ b/docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md @@ -1,27 +1,25 @@ # Bug Server 手动触发设计 -## 目标与已确认方案 +## 目标 -维护者无需创建临时 PR,即可通过 `Bug Server CI` 的 `workflow_dispatch` 验证外部 PR。用户已确认输入 PR 编号与完整 head SHA,并要求构建和持有 token 的上传过程分离。本次从 `develop` 创建 `codex/bugserver-workflow-dispatch` 实现。 +维护者输入 PR 编号和已 review 的完整 head SHA,即可测试外部 PR。PR 构建在 `pull_request` 上下文中完成,默认分支的手动入口只校验和上传产物。 -## 取舍 +## 数据流与权限边界 -- 采用独立的手动验证、构建、上传 jobs,保留已有 push / pull_request 自动流程。 -- 不改用 `pull_request_target` 执行外部 PR 代码。 -- 不在单个 job 中先构建再注入 token:构建期间启动的进程可能继续存在。 +1. `bug-server-pr-bundle.yml` 仅由 `pull_request` 触发。在只读仓库权限、无持久化 checkout 凭据、无 Bug Server token 的 runner 上检出准确 head SHA,执行 Rush 构建。缓存写入作用域属于该 PR,不属于默认分支。产物名为 `bug-server-pr--`,保留 7 天。 +2. `bug-server.yml` 的手动入口只允许默认分支,保留 `pr_number`、`head_sha`。可信脚本校验输入、base 仓库及 PR 当前 head,再从指定 PR bundle workflow 查找成功运行。 +3. 来源校验绑定 workflow ID/路径、事件、运行状态、base/head 仓库 ID、源分支及 run head SHA。fork 的运行记录可能没有 PR 列表,不能因此拒绝所有外部 PR;如列表存在则还需匹配 PR 编号。 +4. 选择最新匹配运行中唯一且未过期的命名产物,复核 artifact API 的 run ID、仓库 ID 与 SHA。失败时要求先成功运行 PR bundle 工作流,不回退到其他提交或较旧运行。 +5. 提交 job 只检出 `github.workflow_sha` 对应的可信脚本。通过 artifact ID 下载 ZIP,只接受一个名为 `index.js` 的普通文件,最大 64 MiB。可信 Python 脚本只把文件字节写入固定位置,不按 ZIP 路径解压,不执行产物。 +6. 可信 TypeScript 客户端的依赖独立安装且禁用 lifecycle scripts。仅最后的 API 调用 step 注入 `BUG_SERVER_TOKEN`;PR 元数据与产物来源由可信校验 job 提供。summary 记录 PR、SHA 和来源构建。 +7. 两个 workflow 默认 `contents: read`;查询 PR 需要 `pull-requests: read`,查询/下载 artifact 需要 `actions: read`。原有 push / pull_request 自动 Bug Server 步骤保持原来的构建和测试行为。 -## 数据流与边界 +## 维护者操作变化 -1. 只允许从默认分支运行手动入口。验证 job 从 workflow 的固定 SHA 检出可信校验脚本。 -2. 校验 PR 编号、40 位十六进制 SHA,并通过 GitHub API 确认 SHA 等于该 PR 当前 head。错误在构建前终止。 -3. 构建 job 使用 `cache-mode: none` 显式禁止缓存读写,并在检出 PR 代码前通过 JavaScript action 确认 runner 报告的模式为 `none`,否则终止。随后在独立 runner 中检出已验证的固定 SHA,不持有 Bug Server secret、不保留 checkout 凭据。只上传 `tools/bugserver-trigger/dist/index.js`。GitHub API 权限和缓存权限分别控制;不配置缓存步骤并不等于没有缓存权限。 -4. 上传 job 在新的 runner 中检出 workflow SHA 对应的可信触发脚本。独立安装该脚本所需的固定版本依赖,不执行 PR 的安装脚本或产物。产物仅作为文件上传。 -5. 仅调用触发脚本的 step 注入 `BUG_SERVER_TOKEN`。传给现有脚本的提交、PR ref、源分支信息来自验证 job,避免记录成 develop 的提交。 -6. Actions summary 记录 PR 与测试 SHA;现有脚本继续输出 `scmVersion`、`bundleId` 和用例结果。 -7. workflow 顶层显式设置 `contents: read`,使原有自动构建也不再继承仓库默认写权限;仅 PR 校验 job 额外需要 `pull-requests: read`。 +先等 `Bug Server PR Bundle` 对该 SHA 构建成功,再运行手动入口。产物缺失或过期时重跑 bundle 工作流。新增工作流之前的旧 PR 需要更新或重新打开以触发新 PR 事件;重跑旧定义不能生成新工作流。fork Actions 首次运行可能需要维护者批准。 ## 验证 -使用 Node 内置测试覆盖输入校验、默认分支限制、SHA 不匹配以及 fork PR 成功解析;actionlint 校验 workflow。以本地 mock HTTP 模拟上传、SCM 构建和图片测试,验证可信脚本的独立运行及元数据传递。线上触发需要入口合入默认分支后执行。 +Node 测试覆盖输入与产物来源校验。Python 测试覆盖正常字节、可执行文本仅作为数据、路径穿越、额外文件、重复文件、链接/特殊文件、体积限制和禁止覆盖目标文件。actionlint 与 CodeQL 必须通过,不以关闭告警作为修复。真实 PR bundle 的查找、下载、读取及 mock 客户端上传在合并前验证;默认分支完整手动测试在合并后验收。 -安全修复另行验证 GitHub 原生 `cache-mode` 配置、runner 实际模式和 token 权限。2026-09-17 的 actionlint 1.7.12 与 CodeQL 缓存污染规则尚未完整识别该字段,工具诊断与平台验证结果分别记录,详见 [安全修复计划](../plans/2026-09-17-bug-server-security-fix.md)。 +此前的 `cache-mode: none` 已在平台验证,但未完成扫描验收;最终方案移除默认分支内的 PR 构建,不再依赖该配置。 diff --git a/tools/bugserver-trigger/README.md b/tools/bugserver-trigger/README.md index 8889e637a..6eafaa8f0 100644 --- a/tools/bugserver-trigger/README.md +++ b/tools/bugserver-trigger/README.md @@ -4,7 +4,7 @@ ## Manually test a PR -After the manual workflow is merged into the repository's default branch (`develop`), maintainers with repository write access can open **Actions → Bug Server CI → Run workflow**. Select **develop**, then enter: +After the workflows are merged into the repository's default branch (`develop`), wait for **Bug Server PR Bundle** to succeed for the reviewed PR head. Fork runs may need a maintainer's approval. Then maintainers with repository write access can open **Actions → Bug Server CI → Run workflow**. Select **develop**, then enter: - `pr_number`: the PR number, including PRs from external forks. - `head_sha`: the full 40-character SHA of the PR head that you reviewed. @@ -21,19 +21,19 @@ gh workflow run bug-server.yml \ Use the currently reviewed PR head; the example SHA becomes invalid if that PR changes. The workflow rejects non-default workflow branches, malformed inputs and a SHA that differs from the PR's current head. It builds the exact requested **head commit**, not GitHub's generated merge commit. Updates after validation cannot change the commit being built. -The run appears under Actions; this manual run does not automatically attach a check or comment to the external PR. Its summary records the PR URL and tested head. The **Trigger Bug Server for reviewed PR** step prints `scmVersion`, `bundleId`, and the result counts, which identify the run in Bug Server. A missing token, failed SCM build or failed photo test makes the job fail. +The run appears under Actions; this manual run does not automatically attach a check or comment to the external PR. Its summary records the PR URL, tested head and source build run. The **Trigger Bug Server for reviewed PR** step prints `scmVersion`, `bundleId`, and the result counts, which identify the run in Bug Server. A missing token, failed SCM build or failed photo test makes the job fail. -## Execution boundaries +The manual entry consumes an existing PR bundle; it does not build PR code. Artifacts are retained for 7 days. If the build or artifact is missing, failed or expired, approve/wait for/re-run **Bug Server PR Bundle** before dispatching again. For a PR opened before this workflow was introduced, update or reopen the PR to trigger a new PR event; re-running an old workflow definition does not create the new bundle workflow. -The manual workflow uses three separate jobs: +## Execution boundaries -1. Validate the PR and reviewed SHA using the GitHub API. -2. Build the reviewed PR with read-only repository access and `cache-mode: none`, which denies cache reads and writes independently of `GITHUB_TOKEN`. A JavaScript action verifies the runner reports this mode before checking out PR code. Keep checkout credentials disabled and the Bug Server token on the separate submission runner. Upload only the generated bundle. -3. On a fresh runner, use the trigger script from the default-branch workflow commit. Install its dependencies separately with npm lifecycle scripts disabled, download the bundle as data, and pass the token only to the API client step. This job never executes the PR bundle or its package scripts. +1. **Bug Server PR Bundle** runs only on `pull_request`, builds the exact head with read-only repository permissions, disabled persisted checkout credentials and no Bug Server token. Any cache writes are confined to the PR scope. It uploads `bug-server-pr--`. +2. The manual **resolve-manual-target** job validates the current PR head and source workflow ID/path, PR event, successful run, repository IDs, source branch and run SHA. It requires one non-expired artifact with matching GitHub API provenance. Fork runs can omit PR associations; the repository/branch/SHA checks still bind the source. +3. **submit-manual-bundle** uses scripts from the immutable default-branch workflow commit. It downloads the selected artifact ID and accepts only a single regular `index.js` entry, up to 64 MiB. The trusted extractor writes bytes to a fixed path without extracting archive paths. The client only uploads those bytes; it never executes the bundle or PR package scripts. -The workflow defaults to `contents: read`; only the PR validation job additionally requests `pull-requests: read`. Existing push and pull-request automatic runs retain their build and test steps with read-only repository permissions. A fork PR's automatic run still cannot obtain repository secrets; use the manual entry for Bug Server validation. +Both workflows default to `contents: read`. Manual lookup and download jobs also need `actions: read`, and target validation needs `pull-requests: read`. The Bug Server token is injected only into the final API client step. Existing push and pull-request automatic runs retain their build and test behavior with read-only repository permissions. A fork PR's automatic Bug Server run still cannot obtain repository secrets; use the manual entry for Bug Server validation. -If **Verify cache isolation** fails, stop and check the runner's cache-mode support. Do not set `ACTIONS_CACHE_MODE` yourself or remove the guard: the job-level `cache-mode` setting must restrict the cache token. The runner exposes the mode to JavaScript actions, not ordinary shell steps. +The default-branch manual workflow does not check out or build PR code. This replaces the earlier `cache-mode` approach and does not require scanner exceptions. ## Local validation @@ -41,7 +41,6 @@ From the repository root: ```sh node --test .github/scripts/bug-server-dispatch.test.cjs -actionlint .github/workflows/bug-server.yml +python3 -m unittest discover -s .github/scripts -p 'test_extract_bug_server_bundle.py' +actionlint .github/workflows/bug-server.yml .github/workflows/bug-server-pr-bundle.yml ``` - -As of 2026-09-17, actionlint 1.7.12 does not recognize `cache-mode`, and CodeQL's cache-poisoning rule does not account for it. Record these diagnostics separately from the GitHub runtime verification; do not describe them as passing or disable the security rule. See the [security fix plan](../../docs/superpowers/plans/2026-09-17-bug-server-security-fix.md) for evidence and remaining validation. From dac694ecd84345dcccb87729b2f75a3f8912c3b6 Mon Sep 17 00:00:00 2001 From: xile611 Date: Thu, 17 Sep 2026 14:10:44 +0800 Subject: [PATCH 17/18] docs: record Bug Server artifact security verification --- .../2026-09-17-bug-server-pr-artifact.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-09-17-bug-server-pr-artifact.md b/docs/superpowers/plans/2026-09-17-bug-server-pr-artifact.md index e66f8fe06..5cb1f2821 100644 --- a/docs/superpowers/plans/2026-09-17-bug-server-pr-artifact.md +++ b/docs/superpowers/plans/2026-09-17-bug-server-pr-artifact.md @@ -37,10 +37,10 @@ Files: `.github/workflows/bug-server-pr-bundle.yml`, `.github/workflows/bug-serv ## Task 3: Verify and document -- [ ] Push the update to PR #2134, check CodeQL alert #45 and #46 on the new commit, and require the CodeQL check to pass without dismissals. -- [ ] Wait for the new PR-only bundle workflow to succeed. Invoke the trusted resolver against that real run, download its immutable artifact, verify single-file extraction, and verify the existing upload client with the local mock API. Do not execute the bundle. -- [ ] Update README, design, the previous security plan, PR description and the existing Lark maintenance section. Document that maintainers wait for `Bug Server PR Bundle` before dispatch; missing/expired artifacts require a fresh successful PR bundle run. Existing PRs may need a new PR event after the workflow is merged. -- [ ] Record separate results for security checks, artifact pipeline and the existing photo CI. End-to-end manual dispatch from the default branch remains a post-merge check. +- [x] Push the update to PR #2134, check CodeQL alert #45 and #46 on the new commit, and require the CodeQL check to pass without dismissals. +- [x] Wait for the new PR-only bundle workflow to succeed. Invoke the trusted resolver against that real run, download its immutable artifact, verify single-file extraction, and verify the existing upload client with the local mock API. Do not execute the bundle. +- [x] Update README, design, the previous security plan, PR description and the existing Lark maintenance section. Document that maintainers wait for `Bug Server PR Bundle` before dispatch; missing/expired artifacts require a fresh successful PR bundle run. Existing PRs may need a new PR event after the workflow is merged. +- [x] Record separate results for security checks, artifact pipeline and the existing photo CI. End-to-end manual dispatch from the default branch remains a post-merge check. ## Validation before push @@ -48,3 +48,14 @@ Files: `.github/workflows/bug-server-pr-bundle.yml`, `.github/workflows/bug-serv - Python archive tests: 6 tests passed, including multiple malicious-entry subcases. - actionlint 1.7.12: both final workflows pass without ignored diagnostics. - `git diff --check`: passed. + +## GitHub and integration validation + +Implementation commit: `ae7fc0926581218905a90a3838bfb5d65741f128`. + +- [CodeQL check](https://github.com/VisActor/VRender/runs/105095209881): `success`, no new alerts. Both Actions and JavaScript/TypeScript analyses passed. Alerts #45 and #46 have PR instance state `fixed`; neither was dismissed. +- [PR bundle run 35188318138](https://github.com/VisActor/VRender/actions/runs/35188318138): `success`. Runner initialization confirms `Contents: read` and `Metadata: read`. Its cache mode is `write` in the PR event's cache scope, not the default-branch scope. +- The production resolver and workflow download script were executed locally against the real GitHub API. They selected artifact `10483685493` from that run, bound to PR #2134 and the exact implementation SHA. +- The trusted ZIP reader produced a single 3,210,456-byte bundle, SHA-256 `43e2b49b5edbf3fc1bbc52759b6844ab6608848ec97da666322a510e73f2b79e`. The trusted upload client passed success, photo-failure and missing-token scenarios with a local mock API, which checked that the uploaded bundle bytes were preserved. A separate throwing-JavaScript fixture was also uploaded as data without execution. +- Required pre-push package tests passed. Existing automatic unit/photo CI runs were still running when this record was written; their results are separate from the verified artifact pipeline. +- README, design notes, superseded-plan notices, PR description and Lark maintenance document were updated to the artifact workflow. No merge or default-branch dispatch was performed. The first live manual Bug Server run remains a post-merge check. From a204d2e4d3f736ea88f21b78606531b863725ff0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 17 Sep 2026 07:36:39 +0000 Subject: [PATCH 18/18] build: prelease version 1.1.11 --- ...lease-1.0.11-alpha.1_2026-04-21-06-59.json | 10 ------ ...lease-1.0.11-alpha.1_2026-04-21-06-59.json | 10 ------ ...lease-1.0.11-alpha.1_2026-04-21-06-59.json | 10 ------ ...lease-1.0.11-alpha.1_2026-04-21-06-59.json | 10 ------ .../fix-glyph-state-20260915.json | 10 ------ ...lease-1.0.11-alpha.1_2026-04-21-06-59.json | 10 ------ ...lease-1.0.11-alpha.1_2026-04-21-06-59.json | 10 ------ ...lease-1.0.11-alpha.1_2026-04-21-06-59.json | 10 ------ common/config/rush/pnpm-lock.yaml | 36 +++++++++---------- common/config/rush/version-policies.json | 2 +- docs/package.json | 2 +- packages/react-vrender-utils/CHANGELOG.json | 12 +++++++ packages/react-vrender-utils/CHANGELOG.md | 9 ++++- packages/react-vrender-utils/package.json | 6 ++-- packages/react-vrender/CHANGELOG.json | 12 +++++++ packages/react-vrender/CHANGELOG.md | 9 ++++- packages/react-vrender/package.json | 4 +-- packages/vrender-animate/CHANGELOG.json | 12 +++++++ packages/vrender-animate/CHANGELOG.md | 9 ++++- packages/vrender-animate/package.json | 4 +-- packages/vrender-components/CHANGELOG.json | 12 +++++++ packages/vrender-components/CHANGELOG.md | 9 ++++- packages/vrender-components/package.json | 8 ++--- packages/vrender-core/CHANGELOG.json | 17 +++++++++ packages/vrender-core/CHANGELOG.md | 13 ++++++- packages/vrender-core/package.json | 2 +- packages/vrender-kits/CHANGELOG.json | 12 +++++++ packages/vrender-kits/CHANGELOG.md | 9 ++++- packages/vrender-kits/package.json | 4 +-- packages/vrender/CHANGELOG.json | 12 +++++++ packages/vrender/CHANGELOG.md | 9 ++++- packages/vrender/package.json | 10 +++--- tools/bugserver-trigger/package.json | 10 +++--- 33 files changed, 193 insertions(+), 131 deletions(-) delete mode 100644 common/changes/@visactor/react-vrender-utils/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json delete mode 100644 common/changes/@visactor/react-vrender/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json delete mode 100644 common/changes/@visactor/vrender-animate/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json delete mode 100644 common/changes/@visactor/vrender-components/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json delete mode 100644 common/changes/@visactor/vrender-core/fix-glyph-state-20260915.json delete mode 100644 common/changes/@visactor/vrender-core/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json delete mode 100644 common/changes/@visactor/vrender-kits/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json delete mode 100644 common/changes/@visactor/vrender/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json diff --git a/common/changes/@visactor/react-vrender-utils/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json b/common/changes/@visactor/react-vrender-utils/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json deleted file mode 100644 index f3e2462d4..000000000 --- a/common/changes/@visactor/react-vrender-utils/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "changes": [ - { - "packageName": "@visactor/react-vrender-utils", - "comment": "performance: memo leak problem of ticker. fix#2075", - "type": "none" - } - ], - "packageName": "@visactor/react-vrender-utils" -} \ No newline at end of file diff --git a/common/changes/@visactor/react-vrender/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json b/common/changes/@visactor/react-vrender/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json deleted file mode 100644 index e3305b32f..000000000 --- a/common/changes/@visactor/react-vrender/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "changes": [ - { - "packageName": "@visactor/react-vrender", - "comment": "performance: memo leak problem of ticker. fix#2075", - "type": "none" - } - ], - "packageName": "@visactor/react-vrender" -} \ No newline at end of file diff --git a/common/changes/@visactor/vrender-animate/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json b/common/changes/@visactor/vrender-animate/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json deleted file mode 100644 index ff1d18f93..000000000 --- a/common/changes/@visactor/vrender-animate/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "changes": [ - { - "packageName": "@visactor/vrender-animate", - "comment": "performance: memo leak problem of ticker. fix#2075", - "type": "none" - } - ], - "packageName": "@visactor/vrender-animate" -} \ No newline at end of file diff --git a/common/changes/@visactor/vrender-components/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json b/common/changes/@visactor/vrender-components/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json deleted file mode 100644 index c66909514..000000000 --- a/common/changes/@visactor/vrender-components/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "changes": [ - { - "packageName": "@visactor/vrender-components", - "comment": "performance: memo leak problem of ticker. fix#2075", - "type": "none" - } - ], - "packageName": "@visactor/vrender-components" -} \ No newline at end of file diff --git a/common/changes/@visactor/vrender-core/fix-glyph-state-20260915.json b/common/changes/@visactor/vrender-core/fix-glyph-state-20260915.json deleted file mode 100644 index 2efe59151..000000000 --- a/common/changes/@visactor/vrender-core/fix-glyph-state-20260915.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "changes": [ - { - "packageName": "@visactor/vrender-core", - "comment": "统一 Glyph 状态生命周期,保留旧状态覆盖顺序,补齐派生子图形同步与属性撤销,并修复内部中断状态动画污染基础属性的问题。", - "type": "patch" - } - ], - "packageName": "@visactor/vrender-core" -} diff --git a/common/changes/@visactor/vrender-core/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json b/common/changes/@visactor/vrender-core/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json deleted file mode 100644 index 45eb7a116..000000000 --- a/common/changes/@visactor/vrender-core/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "changes": [ - { - "packageName": "@visactor/vrender-core", - "comment": "performance: memo leak problem of ticker. fix#2075", - "type": "none" - } - ], - "packageName": "@visactor/vrender-core" -} \ No newline at end of file diff --git a/common/changes/@visactor/vrender-kits/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json b/common/changes/@visactor/vrender-kits/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json deleted file mode 100644 index f188a4836..000000000 --- a/common/changes/@visactor/vrender-kits/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "changes": [ - { - "packageName": "@visactor/vrender-kits", - "comment": "performance: memo leak problem of ticker. fix#2075", - "type": "none" - } - ], - "packageName": "@visactor/vrender-kits" -} \ No newline at end of file diff --git a/common/changes/@visactor/vrender/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json b/common/changes/@visactor/vrender/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json deleted file mode 100644 index e24753093..000000000 --- a/common/changes/@visactor/vrender/pre-release-1.0.11-alpha.1_2026-04-21-06-59.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "changes": [ - { - "packageName": "@visactor/vrender", - "comment": "performance: memo leak problem of ticker. fix#2075", - "type": "none" - } - ], - "packageName": "@visactor/vrender" -} \ No newline at end of file diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 688894385..1044918ea 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: specifier: ~0.5.7 version: 0.5.7 '@visactor/vrender': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../packages/vrender '@visactor/vutils': specifier: ~1.0.12 @@ -95,7 +95,7 @@ importers: ../../packages/react-vrender: dependencies: '@visactor/vrender': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender '@visactor/vutils': specifier: ~1.0.12 @@ -168,10 +168,10 @@ importers: ../../packages/react-vrender-utils: dependencies: '@visactor/react-vrender': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../react-vrender '@visactor/vrender': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender '@visactor/vutils': specifier: ~1.0.12 @@ -241,16 +241,16 @@ importers: ../../packages/vrender: dependencies: '@visactor/vrender-animate': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender-animate '@visactor/vrender-components': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender-components '@visactor/vrender-core': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender-core '@visactor/vrender-kits': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender-kits devDependencies: '@internal/bundler': @@ -320,7 +320,7 @@ importers: ../../packages/vrender-animate: dependencies: '@visactor/vrender-core': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender-core '@visactor/vutils': specifier: ~1.0.12 @@ -393,13 +393,13 @@ importers: ../../packages/vrender-components: dependencies: '@visactor/vrender-animate': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender-animate '@visactor/vrender-core': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender-core '@visactor/vrender-kits': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender-kits '@visactor/vscale': specifier: ~1.0.12 @@ -518,7 +518,7 @@ importers: specifier: 2.4.1 version: 2.4.1 '@visactor/vrender-core': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender-core '@visactor/vutils': specifier: ~1.0.12 @@ -649,19 +649,19 @@ importers: ../../tools/bugserver-trigger: dependencies: '@visactor/vrender': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../../packages/vrender '@visactor/vrender-animate': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../../packages/vrender-animate '@visactor/vrender-components': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../../packages/vrender-components '@visactor/vrender-core': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../../packages/vrender-core '@visactor/vrender-kits': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../../packages/vrender-kits devDependencies: '@internal/bundler': diff --git a/common/config/rush/version-policies.json b/common/config/rush/version-policies.json index de55d2119..20f8b2bf1 100644 --- a/common/config/rush/version-policies.json +++ b/common/config/rush/version-policies.json @@ -1 +1 @@ -[{"definitionName":"lockStepVersion","policyName":"vrenderMain","version":"1.1.10","nextBump":"patch"}] +[{"definitionName":"lockStepVersion","policyName":"vrenderMain","version":"1.1.11","nextBump":"patch"}] diff --git a/docs/package.json b/docs/package.json index 9133991fd..6fd1203a7 100644 --- a/docs/package.json +++ b/docs/package.json @@ -13,7 +13,7 @@ "@visactor/vchart": "1.3.0", "@visactor/vutils": "~1.0.12", "@visactor/vgrammar": "~0.5.7", - "@visactor/vrender": "workspace:1.1.10", + "@visactor/vrender": "workspace:1.1.11", "markdown-it": "^13.0.0", "highlight.js": "^11.8.0", "axios": "^1.4.0", diff --git a/packages/react-vrender-utils/CHANGELOG.json b/packages/react-vrender-utils/CHANGELOG.json index 713c7ddcc..5770acb6f 100644 --- a/packages/react-vrender-utils/CHANGELOG.json +++ b/packages/react-vrender-utils/CHANGELOG.json @@ -1,6 +1,18 @@ { "name": "@visactor/react-vrender-utils", "entries": [ + { + "version": "1.1.11", + "tag": "@visactor/react-vrender-utils_v1.1.11", + "date": "Thu, 17 Sep 2026 07:31:09 GMT", + "comments": { + "none": [ + { + "comment": "performance: memo leak problem of ticker. fix#2075" + } + ] + } + }, { "version": "1.1.10", "tag": "@visactor/react-vrender-utils_v1.1.10", diff --git a/packages/react-vrender-utils/CHANGELOG.md b/packages/react-vrender-utils/CHANGELOG.md index 4ff1d721e..6b2f3c14a 100644 --- a/packages/react-vrender-utils/CHANGELOG.md +++ b/packages/react-vrender-utils/CHANGELOG.md @@ -1,6 +1,13 @@ # Change Log - @visactor/react-vrender-utils -This log was last generated on Sat, 29 Aug 2026 08:21:58 GMT and should not be manually modified. +This log was last generated on Thu, 17 Sep 2026 07:31:09 GMT and should not be manually modified. + +## 1.1.11 +Thu, 17 Sep 2026 07:31:09 GMT + +### Updates + +- performance: memo leak problem of ticker. fix#2075 ## 1.1.10 Sat, 29 Aug 2026 08:21:58 GMT diff --git a/packages/react-vrender-utils/package.json b/packages/react-vrender-utils/package.json index 2a4454144..e92c5a256 100644 --- a/packages/react-vrender-utils/package.json +++ b/packages/react-vrender-utils/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/react-vrender-utils", - "version": "1.1.10", + "version": "1.1.11", "description": "", "sideEffects": false, "main": "cjs/index.js", @@ -27,8 +27,8 @@ "react-dom": "^18.2.0" }, "dependencies": { - "@visactor/vrender": "workspace:1.1.10", - "@visactor/react-vrender": "workspace:1.1.10", + "@visactor/vrender": "workspace:1.1.11", + "@visactor/react-vrender": "workspace:1.1.11", "@visactor/vutils": "~1.0.12", "react-reconciler": "^0.29.0", "tslib": "^2.3.1" diff --git a/packages/react-vrender/CHANGELOG.json b/packages/react-vrender/CHANGELOG.json index 407788ac6..72105fcdf 100644 --- a/packages/react-vrender/CHANGELOG.json +++ b/packages/react-vrender/CHANGELOG.json @@ -1,6 +1,18 @@ { "name": "@visactor/react-vrender", "entries": [ + { + "version": "1.1.11", + "tag": "@visactor/react-vrender_v1.1.11", + "date": "Thu, 17 Sep 2026 07:31:09 GMT", + "comments": { + "none": [ + { + "comment": "performance: memo leak problem of ticker. fix#2075" + } + ] + } + }, { "version": "1.1.10", "tag": "@visactor/react-vrender_v1.1.10", diff --git a/packages/react-vrender/CHANGELOG.md b/packages/react-vrender/CHANGELOG.md index b7081c5b0..702541820 100644 --- a/packages/react-vrender/CHANGELOG.md +++ b/packages/react-vrender/CHANGELOG.md @@ -1,6 +1,13 @@ # Change Log - @visactor/react-vrender -This log was last generated on Sat, 29 Aug 2026 08:21:58 GMT and should not be manually modified. +This log was last generated on Thu, 17 Sep 2026 07:31:09 GMT and should not be manually modified. + +## 1.1.11 +Thu, 17 Sep 2026 07:31:09 GMT + +### Updates + +- performance: memo leak problem of ticker. fix#2075 ## 1.1.10 Sat, 29 Aug 2026 08:21:58 GMT diff --git a/packages/react-vrender/package.json b/packages/react-vrender/package.json index 7ce44971c..23e0d0404 100644 --- a/packages/react-vrender/package.json +++ b/packages/react-vrender/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/react-vrender", - "version": "1.1.10", + "version": "1.1.11", "description": "", "sideEffects": false, "main": "cjs/index.js", @@ -26,7 +26,7 @@ "react": "^18.2.0" }, "dependencies": { - "@visactor/vrender": "workspace:1.1.10", + "@visactor/vrender": "workspace:1.1.11", "@visactor/vutils": "~1.0.12", "react-reconciler": "^0.29.0", "tslib": "^2.3.1" diff --git a/packages/vrender-animate/CHANGELOG.json b/packages/vrender-animate/CHANGELOG.json index c5eff74e5..ed7224b9a 100644 --- a/packages/vrender-animate/CHANGELOG.json +++ b/packages/vrender-animate/CHANGELOG.json @@ -1,6 +1,18 @@ { "name": "@visactor/vrender-animate", "entries": [ + { + "version": "1.1.11", + "tag": "@visactor/vrender-animate_v1.1.11", + "date": "Thu, 17 Sep 2026 07:31:09 GMT", + "comments": { + "none": [ + { + "comment": "performance: memo leak problem of ticker. fix#2075" + } + ] + } + }, { "version": "1.1.10", "tag": "@visactor/vrender-animate_v1.1.10", diff --git a/packages/vrender-animate/CHANGELOG.md b/packages/vrender-animate/CHANGELOG.md index ae554386c..36b9167a7 100644 --- a/packages/vrender-animate/CHANGELOG.md +++ b/packages/vrender-animate/CHANGELOG.md @@ -1,6 +1,13 @@ # Change Log - @visactor/vrender-animate -This log was last generated on Sat, 29 Aug 2026 08:21:58 GMT and should not be manually modified. +This log was last generated on Thu, 17 Sep 2026 07:31:09 GMT and should not be manually modified. + +## 1.1.11 +Thu, 17 Sep 2026 07:31:09 GMT + +### Updates + +- performance: memo leak problem of ticker. fix#2075 ## 1.1.10 Sat, 29 Aug 2026 08:21:58 GMT diff --git a/packages/vrender-animate/package.json b/packages/vrender-animate/package.json index 0158eb1a7..67f2c5cbb 100644 --- a/packages/vrender-animate/package.json +++ b/packages/vrender-animate/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vrender-animate", - "version": "1.1.10", + "version": "1.1.11", "description": "", "sideEffects": false, "main": "cjs/index.js", @@ -37,7 +37,7 @@ }, "dependencies": { "@visactor/vutils": "~1.0.12", - "@visactor/vrender-core": "workspace:1.1.10" + "@visactor/vrender-core": "workspace:1.1.11" }, "devDependencies": { "@internal/bundler": "workspace:*", diff --git a/packages/vrender-components/CHANGELOG.json b/packages/vrender-components/CHANGELOG.json index 75d73dd19..5f6345c1c 100644 --- a/packages/vrender-components/CHANGELOG.json +++ b/packages/vrender-components/CHANGELOG.json @@ -1,6 +1,18 @@ { "name": "@visactor/vrender-components", "entries": [ + { + "version": "1.1.11", + "tag": "@visactor/vrender-components_v1.1.11", + "date": "Thu, 17 Sep 2026 07:31:09 GMT", + "comments": { + "none": [ + { + "comment": "performance: memo leak problem of ticker. fix#2075" + } + ] + } + }, { "version": "1.1.10", "tag": "@visactor/vrender-components_v1.1.10", diff --git a/packages/vrender-components/CHANGELOG.md b/packages/vrender-components/CHANGELOG.md index 12bf2f7a3..2e2859b60 100644 --- a/packages/vrender-components/CHANGELOG.md +++ b/packages/vrender-components/CHANGELOG.md @@ -1,6 +1,13 @@ # Change Log - @visactor/vrender-components -This log was last generated on Sat, 29 Aug 2026 08:21:58 GMT and should not be manually modified. +This log was last generated on Thu, 17 Sep 2026 07:31:09 GMT and should not be manually modified. + +## 1.1.11 +Thu, 17 Sep 2026 07:31:09 GMT + +### Updates + +- performance: memo leak problem of ticker. fix#2075 ## 1.1.10 Sat, 29 Aug 2026 08:21:58 GMT diff --git a/packages/vrender-components/package.json b/packages/vrender-components/package.json index 8c52226af..f07e58d9c 100644 --- a/packages/vrender-components/package.json +++ b/packages/vrender-components/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vrender-components", - "version": "1.1.10", + "version": "1.1.11", "description": "components library for dp visualization", "sideEffects": false, "main": "cjs/index.js", @@ -69,9 +69,9 @@ "dependencies": { "@visactor/vutils": "~1.0.12", "@visactor/vscale": "~1.0.12", - "@visactor/vrender-core": "workspace:1.1.10", - "@visactor/vrender-kits": "workspace:1.1.10", - "@visactor/vrender-animate": "workspace:1.1.10" + "@visactor/vrender-core": "workspace:1.1.11", + "@visactor/vrender-kits": "workspace:1.1.11", + "@visactor/vrender-animate": "workspace:1.1.11" }, "devDependencies": { "@internal/bundler": "workspace:*", diff --git a/packages/vrender-core/CHANGELOG.json b/packages/vrender-core/CHANGELOG.json index 2285cc0bb..4c91b2a79 100644 --- a/packages/vrender-core/CHANGELOG.json +++ b/packages/vrender-core/CHANGELOG.json @@ -1,6 +1,23 @@ { "name": "@visactor/vrender-core", "entries": [ + { + "version": "1.1.11", + "tag": "@visactor/vrender-core_v1.1.11", + "date": "Thu, 17 Sep 2026 07:31:09 GMT", + "comments": { + "patch": [ + { + "comment": "统一 Glyph 状态生命周期,保留旧状态覆盖顺序,补齐派生子图形同步与属性撤销,并修复内部中断状态动画污染基础属性的问题。" + } + ], + "none": [ + { + "comment": "performance: memo leak problem of ticker. fix#2075" + } + ] + } + }, { "version": "1.1.10", "tag": "@visactor/vrender-core_v1.1.10", diff --git a/packages/vrender-core/CHANGELOG.md b/packages/vrender-core/CHANGELOG.md index c7915876d..ea619f642 100644 --- a/packages/vrender-core/CHANGELOG.md +++ b/packages/vrender-core/CHANGELOG.md @@ -1,6 +1,17 @@ # Change Log - @visactor/vrender-core -This log was last generated on Sat, 29 Aug 2026 08:21:58 GMT and should not be manually modified. +This log was last generated on Thu, 17 Sep 2026 07:31:09 GMT and should not be manually modified. + +## 1.1.11 +Thu, 17 Sep 2026 07:31:09 GMT + +### Patches + +- 统一 Glyph 状态生命周期,保留旧状态覆盖顺序,补齐派生子图形同步与属性撤销,并修复内部中断状态动画污染基础属性的问题。 + +### Updates + +- performance: memo leak problem of ticker. fix#2075 ## 1.1.10 Sat, 29 Aug 2026 08:21:58 GMT diff --git a/packages/vrender-core/package.json b/packages/vrender-core/package.json index f133c6e51..e48d30767 100644 --- a/packages/vrender-core/package.json +++ b/packages/vrender-core/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vrender-core", - "version": "1.1.10", + "version": "1.1.11", "description": "", "sideEffects": [ "./src/modules.ts", diff --git a/packages/vrender-kits/CHANGELOG.json b/packages/vrender-kits/CHANGELOG.json index 6cffb6552..57a14a7fc 100644 --- a/packages/vrender-kits/CHANGELOG.json +++ b/packages/vrender-kits/CHANGELOG.json @@ -1,6 +1,18 @@ { "name": "@visactor/vrender-kits", "entries": [ + { + "version": "1.1.11", + "tag": "@visactor/vrender-kits_v1.1.11", + "date": "Thu, 17 Sep 2026 07:31:09 GMT", + "comments": { + "none": [ + { + "comment": "performance: memo leak problem of ticker. fix#2075" + } + ] + } + }, { "version": "1.1.10", "tag": "@visactor/vrender-kits_v1.1.10", diff --git a/packages/vrender-kits/CHANGELOG.md b/packages/vrender-kits/CHANGELOG.md index 6e5a18dc1..25eca1dde 100644 --- a/packages/vrender-kits/CHANGELOG.md +++ b/packages/vrender-kits/CHANGELOG.md @@ -1,6 +1,13 @@ # Change Log - @visactor/vrender-kits -This log was last generated on Sat, 29 Aug 2026 08:21:58 GMT and should not be manually modified. +This log was last generated on Thu, 17 Sep 2026 07:31:09 GMT and should not be manually modified. + +## 1.1.11 +Thu, 17 Sep 2026 07:31:09 GMT + +### Updates + +- performance: memo leak problem of ticker. fix#2075 ## 1.1.10 Sat, 29 Aug 2026 08:21:58 GMT diff --git a/packages/vrender-kits/package.json b/packages/vrender-kits/package.json index 5f5342587..463e0bf28 100644 --- a/packages/vrender-kits/package.json +++ b/packages/vrender-kits/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vrender-kits", - "version": "1.1.10", + "version": "1.1.11", "description": "", "sideEffects": false, "main": "cjs/index-node.js", @@ -38,7 +38,7 @@ }, "dependencies": { "@visactor/vutils": "~1.0.12", - "@visactor/vrender-core": "workspace:1.1.10", + "@visactor/vrender-core": "workspace:1.1.11", "@resvg/resvg-js": "2.4.1", "roughjs": "4.6.6", "gifuct-js": "2.1.2", diff --git a/packages/vrender/CHANGELOG.json b/packages/vrender/CHANGELOG.json index 974622960..6e610ed51 100644 --- a/packages/vrender/CHANGELOG.json +++ b/packages/vrender/CHANGELOG.json @@ -1,6 +1,18 @@ { "name": "@visactor/vrender", "entries": [ + { + "version": "1.1.11", + "tag": "@visactor/vrender_v1.1.11", + "date": "Thu, 17 Sep 2026 07:31:09 GMT", + "comments": { + "none": [ + { + "comment": "performance: memo leak problem of ticker. fix#2075" + } + ] + } + }, { "version": "1.1.10", "tag": "@visactor/vrender_v1.1.10", diff --git a/packages/vrender/CHANGELOG.md b/packages/vrender/CHANGELOG.md index c00ebcef5..d4c98a15f 100644 --- a/packages/vrender/CHANGELOG.md +++ b/packages/vrender/CHANGELOG.md @@ -1,6 +1,13 @@ # Change Log - @visactor/vrender -This log was last generated on Sat, 29 Aug 2026 08:21:58 GMT and should not be manually modified. +This log was last generated on Thu, 17 Sep 2026 07:31:09 GMT and should not be manually modified. + +## 1.1.11 +Thu, 17 Sep 2026 07:31:09 GMT + +### Updates + +- performance: memo leak problem of ticker. fix#2075 ## 1.1.10 Sat, 29 Aug 2026 08:21:58 GMT diff --git a/packages/vrender/package.json b/packages/vrender/package.json index aedd1c87c..3c0c7563d 100644 --- a/packages/vrender/package.json +++ b/packages/vrender/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vrender", - "version": "1.1.10", + "version": "1.1.11", "description": "", "sideEffects": true, "main": "cjs/index.js", @@ -32,10 +32,10 @@ "test-watch": "cross-env DEBUG_MODE=1 jest --watch -c jest.config.js" }, "dependencies": { - "@visactor/vrender-core": "workspace:1.1.10", - "@visactor/vrender-kits": "workspace:1.1.10", - "@visactor/vrender-animate": "workspace:1.1.10", - "@visactor/vrender-components": "workspace:1.1.10" + "@visactor/vrender-core": "workspace:1.1.11", + "@visactor/vrender-kits": "workspace:1.1.11", + "@visactor/vrender-animate": "workspace:1.1.11", + "@visactor/vrender-components": "workspace:1.1.11" }, "devDependencies": { "@internal/bundler": "workspace:*", diff --git a/tools/bugserver-trigger/package.json b/tools/bugserver-trigger/package.json index 350b6e127..f8efe9134 100644 --- a/tools/bugserver-trigger/package.json +++ b/tools/bugserver-trigger/package.json @@ -8,11 +8,11 @@ "ci": "ts-node --transpileOnly --skipProject ./scripts/trigger-test.ts" }, "dependencies": { - "@visactor/vrender": "workspace:1.1.10", - "@visactor/vrender-core": "workspace:1.1.10", - "@visactor/vrender-kits": "workspace:1.1.10", - "@visactor/vrender-components": "workspace:1.1.10", - "@visactor/vrender-animate": "workspace:1.1.10" + "@visactor/vrender": "workspace:1.1.11", + "@visactor/vrender-core": "workspace:1.1.11", + "@visactor/vrender-kits": "workspace:1.1.11", + "@visactor/vrender-components": "workspace:1.1.11", + "@visactor/vrender-animate": "workspace:1.1.11" }, "devDependencies": { "@rushstack/eslint-patch": "~1.1.4",