diff --git a/docs/assets/guide/en/asd/Upgrade_Guide/Upgrade_to_1_1_0.md b/docs/assets/guide/en/asd/Upgrade_Guide/Upgrade_to_1_1_0.md index 475458c03..6bebd1819 100644 --- a/docs/assets/guide/en/asd/Upgrade_Guide/Upgrade_to_1_1_0.md +++ b/docs/assets/guide/en/asd/Upgrade_Guide/Upgrade_to_1_1_0.md @@ -88,6 +88,31 @@ VRender 1.1.0 makes component, plugin, and custom animation registration boundar - Custom animation can register `basic`, `richtext`, `disappear`, `story`, or the full surface. - The `poptip` plugin is explicit. Use `loadPoptip()` or `installPoptipToApp(app)`; it is not part of the default bootstrap. +### Custom Runtime Contributions Use a Runtime Installer + +If an upper layer injects renderer contributions, draw interceptors, or picker contributions, use the runtime contribution installer instead of directly managing VRender DI/container refresh order: + +```ts +import { installRuntimeContributionModule } from '@visactor/vrender/entries/runtime-contribution'; + +installRuntimeContributionModule(customContributionModule, { + targets: ['graphic-renderer', 'draw-contribution'] +}); +``` + +When `app` is omitted, VRender records the module as a pending runtime contribution and refreshes any shared apps that already exist. It does not create a new app. New apps created later install the pending module after their default bootstrap, so replacement modules can see built-in contribution tokens before calling `rebind`. + +When an app already exists or is passed by the host, pass it explicitly: + +```ts +installRuntimeContributionModule(customContributionModule, { + app, + targets: ['graphic-renderer'] +}); +``` + +Use `{ picker: CanvasPickerContribution }` in `targets` when the module registers picker contributions. The same module object is loaded once per binding context, so it is safe for an upper layer to call the installer before app creation and again after resolving the app. + ## Breaking Changes - `graphic.stateProxy` has been removed. Use `StateDefinition.resolver` for dynamic state styles. @@ -457,6 +482,15 @@ installPoptipToApp(app); This API requires an explicit `app` argument so caller mistakes fail early. If the App has not been created yet and you only need to register poptip capability globally, use `loadPoptip()`. +### When should I use `installRuntimeContributionModule()`? + +Use it for upper-layer custom renderer contribution modules, for example table split-border rendering, custom draw interceptors, or chart/table picker extensions. It handles both states: + +- no app yet: save the runtime contribution module for future app bootstrap and refresh existing shared apps if any; +- app already exists: reinstall the requested app registry targets for that app. + +It is not a replacement for default VRender bootstrap and does not auto-load arbitrary optional features. + ## Not Included in the 1.1.0 Stable Contract - Tier 1 claims for Taro, Feishu, or TT. diff --git a/docs/assets/guide/zh/asd/Upgrade_Guide/Upgrade_to_1_1_0.md b/docs/assets/guide/zh/asd/Upgrade_Guide/Upgrade_to_1_1_0.md index 06578049c..47a65afff 100644 --- a/docs/assets/guide/zh/asd/Upgrade_Guide/Upgrade_to_1_1_0.md +++ b/docs/assets/guide/zh/asd/Upgrade_Guide/Upgrade_to_1_1_0.md @@ -94,6 +94,31 @@ export function registerVRenderBasicAnimationProfile() { - custom animation 可以选择 `basic`、`richtext`、`disappear`、`story` 或 full register。 - `poptip` 插件需要通过 `loadPoptip()` 或 `installPoptipToApp(app)` 显式触发,不属于默认 bootstrap。 +### 自定义 Runtime Contribution 使用统一 Installer + +如果上层需要注入 renderer contribution、draw interceptor 或 picker contribution,应使用 VRender 的 runtime contribution installer,而不是自己维护 DI/container 刷新顺序: + +```ts +import { installRuntimeContributionModule } from '@visactor/vrender/entries/runtime-contribution'; + +installRuntimeContributionModule(customContributionModule, { + targets: ['graphic-renderer', 'draw-contribution'] +}); +``` + +未传 `app` 时,VRender 会把 module 记录为 pending runtime contribution,并刷新当前已经存在的 shared app。它不会创建新 app;后续新建 app 会在默认 bootstrap 完成后安装这份 pending module,保证 replacement module 在执行 `rebind` 前可以看到内置 contribution token。 + +如果 app 已经存在,或由宿主传入,应显式传入 app: + +```ts +installRuntimeContributionModule(customContributionModule, { + app, + targets: ['graphic-renderer'] +}); +``` + +如果 module 注册 picker contribution,在 `targets` 中加入 `{ picker: CanvasPickerContribution }`。同一个 module object 对同一个 binding context 只会加载一次,因此上层可以在 app 创建前调用一次,并在拿到 app 后再次调用。 + ## 破坏性变化 - `graphic.stateProxy` 已移除。动态状态样式应迁移到 `StateDefinition.resolver`。 diff --git a/docs/refactor/bundle-size/VRENDER_ON_DEMAND_CAPABILITY_USAGE.md b/docs/refactor/bundle-size/VRENDER_ON_DEMAND_CAPABILITY_USAGE.md index 9dae8e501..e9838f432 100644 --- a/docs/refactor/bundle-size/VRENDER_ON_DEMAND_CAPABILITY_USAGE.md +++ b/docs/refactor/bundle-size/VRENDER_ON_DEMAND_CAPABILITY_USAGE.md @@ -130,6 +130,68 @@ export function registerVRenderStoryCustomAnimations() { - 如果使用 static profile,上层应通过不同入口文件、构建条件或明确 profile 导出承载选择,而不是在一个文件里静态导入所有 optional group。 - 如果用户选择发生在运行时,使用下一节的动态加载方式。 +## 自定义 Runtime Contribution Module + +上层如果需要注入 renderer contribution、draw interceptor 或 picker contribution,应使用 VRender 的 runtime contribution installer,而不是直接维护 legacy/runtime container 与 app registry 的刷新顺序。 + +Public entry: + +```ts +import { installRuntimeContributionModule } from '@visactor/vrender/entries/runtime-contribution'; +``` + +基础用法: + +```ts +installRuntimeContributionModule(customContributionModule, { + targets: ['graphic-renderer'] +}); +``` + +语义: + +- 如果未传 `app`,module 会登记为 pending runtime contribution。VRender 不会创建新 app,也不会在默认 graphics 尚未安装前提前执行 replacement module。 +- 当前已经存在的 shared app 会立即安装该 module。 +- 后续通过 `@visactor/vrender` app creator 创建的新 app,会在默认 bootstrap 完成后安装 pending module,保证 replacement module 可以看到内置 contribution token。 +- 默认也会安装到 legacy binding context,保持旧兼容路径可用;需要禁用时传 `legacy: false`。 +- 如果传入 `app`,VRender 只把 module 安装到该 app,不会同时扫描其他 shared app。 +- 同一个 module object 对同一个 binding context 只会加载一次,避免 append-style contribution 重复绑定;app-scoped reinstall 仍可以重复调用。 + +`targets` 控制已有 app 需要刷新的 registry: + +| Target | 作用 | +| --- | --- | +| `'graphic-renderer'` | 重新安装 runtime graphic renderer,并调用 renderer `reInit()` 刷新 render contribution | +| `'draw-contribution'` | 重新安装 draw interceptor contribution | +| `{ picker: CanvasPickerContribution }` | 重新安装指定 picker contribution | + +VTable 这类同时注入 rect/group/image/text render contribution、draw interceptor 和 chart picker 的 profile,可以收口成: + +```ts +import { CanvasPickerContribution } from '@visactor/vrender-kits/picker/contributions/constants'; +import { installRuntimeContributionModule } from '@visactor/vrender/entries/runtime-contribution'; + +export function installVTableRuntimeContributions(app?: IApp) { + installRuntimeContributionModule(splitModule, { + app, + targets: ['graphic-renderer', 'draw-contribution', { picker: CanvasPickerContribution }] + }); + + installRuntimeContributionModule(textMeasureModule, { + app, + targets: ['graphic-renderer'] + }); +} +``` + +推荐调用方式: + +1. 在 profile 顶层调用一次,覆盖 app 尚未创建时的 runtime 状态。 +2. 在拿到 app 后、`app.createStage()` 前再调用一次,覆盖 app 已存在或 shared app 被其他上层提前创建的场景。 +3. 不要依赖顶层 import 顺序来判断 renderer 是否已经安装;该判断由 VRender installer 承担。 +4. 不要把业务 contribution 放进 VRender 默认 bootstrap。full/default 行为保持完整,但业务可选能力应由上层 profile 显式安装。 +5. 业务方如果要替换内置 contribution,仍应使用稳定 token 的 `rebind(Token).to(CustomContribution)`;不要只追加同类型 contribution,否则内置 contribution 仍会参与绘制。 + ## 上层动态加载方式 如果上层希望把 optional 能力拆成异步 chunk,可以使用动态 import。推荐仍然保留一个统一入口: diff --git a/packages/vrender-animate/__tests__/unit/animate-extension-closeout.test.ts b/packages/vrender-animate/__tests__/unit/animate-extension-closeout.test.ts index dfc45aede..c7657b785 100644 --- a/packages/vrender-animate/__tests__/unit/animate-extension-closeout.test.ts +++ b/packages/vrender-animate/__tests__/unit/animate-extension-closeout.test.ts @@ -28,6 +28,17 @@ describe('AnimateExtension close-out', () => { target.restoreStaticAttribute(); - expect(restore).toHaveBeenCalledWith({ type: AttributeUpdateType.ANIMATE_END }); + expect(restore).toHaveBeenCalledWith({ type: AttributeUpdateType.ANIMATE_END }, undefined); + }); + + test('should pass excluded key table to the standard static truth restore path', () => { + const restore = jest.fn(); + const target: any = createTarget({ + _restoreAttributeFromStaticTruth: restore + }); + + target.restoreStaticAttribute({ x: true }); + + expect(restore).toHaveBeenCalledWith({ type: AttributeUpdateType.ANIMATE_END }, { x: true }); }); }); 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 ff1ed994c..12fa7853f 100644 --- a/packages/vrender-animate/__tests__/unit/animation-runtime-attribute.test.ts +++ b/packages/vrender-animate/__tests__/unit/animation-runtime-attribute.test.ts @@ -1454,6 +1454,77 @@ describe('D3 pre-handoff animation runtime', () => { expect(rect.getFinalAttribute().y).toBeCloseTo(200, 6); }); + test('parallel update siblings should not restore active sibling attrs when the shorter channel ends', () => { + const { group, ticker, graphicService } = createStageHarness('parallel-update-sibling-restore'); + const initial = { + x: 100, + y: 40, + width: 10, + height: 10, + visible: true + }; + const finalAttrs = { + ...initial, + x: 400, + y: 200 + }; + const rect = createRect(initial); + bindGraphicService(rect as any, graphicService); + rect.setFinalAttributes(initial); + group.appendChild(rect); + + const diffAttrs = { + x: finalAttrs.x, + y: finalAttrs.y + }; + (rect as any).context = { + diffState: 'update', + animationState: 'update', + data: [{ id: 'parallel-update' }], + diffAttrs, + finalAttrs + }; + rect.setFinalAttributes(finalAttrs); + + (rect as any).applyAnimationState( + ['update'], + [ + { + name: 'update_0', + animation: [ + { + type: 'update', + duration: 1000, + easing: 'linear', + options: { excludeChannels: ['y'] } + }, + { + channel: ['y'], + duration: 600, + easing: 'linear' + } + ] + } + ] + ); + + tick(ticker, 599); + expect(rect.attribute.x).toBeCloseTo(279.7, 5); + expect(rect.attribute.y).toBeGreaterThan(initial.y); + expect(rect.attribute.y).toBeLessThan(finalAttrs.y); + + tick(ticker, 2); + expect(rect.attribute.x).toBeCloseTo(280.3, 5); + expect(rect.attribute.x).toBeGreaterThan(initial.x); + expect(rect.attribute.y).toBeCloseTo(finalAttrs.y, 5); + + tick(ticker, 399); + expect(rect.attribute.x).toBeCloseTo(400, 5); + expect(rect.attribute.y).toBeCloseTo(finalAttrs.y, 5); + expect((rect as any).baseAttributes.x).toBeCloseTo(400, 5); + expect((rect as any).baseAttributes.y).toBeCloseTo(finalAttrs.y, 5); + }); + test('non-animation update commits diffAttrs without going through animation executor', () => { const oldAttrs = { x: 0, diff --git a/packages/vrender-animate/src/animate-extension.ts b/packages/vrender-animate/src/animate-extension.ts index 85a0ea560..91b8c7f9c 100644 --- a/packages/vrender-animate/src/animate-extension.ts +++ b/packages/vrender-animate/src/animate-extension.ts @@ -87,8 +87,8 @@ export class AnimateExtension { target.setAttributesAndPreventAnimate(finalAttribute, false, { type: AttributeUpdateType.ANIMATE_BIND }); } - restoreStaticAttribute(): void { - (this as any)._restoreAttributeFromStaticTruth({ type: AttributeUpdateType.ANIMATE_END }); + restoreStaticAttribute(excludedKeys?: Record): void { + (this as any)._restoreAttributeFromStaticTruth({ type: AttributeUpdateType.ANIMATE_END }, excludedKeys); } /** diff --git a/packages/vrender-animate/src/animate.ts b/packages/vrender-animate/src/animate.ts index cbd2bfb5b..edcbf08c7 100644 --- a/packages/vrender-animate/src/animate.ts +++ b/packages/vrender-animate/src/animate.ts @@ -40,6 +40,44 @@ function removeKeysFromRecord | undefined>(record: return nextRecord as T; } +type ExcludedAttrKeys = Record; + +function collectActiveSiblingAttrKeys(target: any, currentAnimate: IAnimate): ExcludedAttrKeys | undefined { + const getTrackedAnimates = target?.getTrackedAnimates; + if (typeof getTrackedAnimates === 'function') { + const trackedAnimates = getTrackedAnimates.call(target) as Map | undefined; + if (trackedAnimates && trackedAnimates.size <= 1) { + return undefined; + } + } + + const forEachTrackedAnimate = target?.forEachTrackedAnimate; + if (typeof forEachTrackedAnimate !== 'function') { + return undefined; + } + + let keys: ExcludedAttrKeys | undefined; + const currentEndProps = currentAnimate.getEndProps?.(); + forEachTrackedAnimate.call(target, (animate: IAnimate) => { + if (animate === currentAnimate || animate.status === AnimateStatus.END) { + return; + } + const endProps = animate.getEndProps?.(); + if (!endProps) { + return; + } + for (const key in endProps) { + if ( + Object.prototype.hasOwnProperty.call(endProps, key) && + !(currentEndProps && Object.prototype.hasOwnProperty.call(currentEndProps, key)) + ) { + (keys ?? (keys = Object.create(null)))[key] = true; + } + } + }); + return keys; +} + export class Animate implements IAnimate { readonly id: string | number; status: AnimateStatus; @@ -157,7 +195,7 @@ export class Animate implements IAnimate { this.onRemove(() => { this.stop(); if (!(this as any).__skipRestoreStaticAttributeOnRemove) { - trackerTarget.restoreStaticAttribute(); + trackerTarget.restoreStaticAttribute(collectActiveSiblingAttrKeys(trackerTarget, this)); } trackerTarget.untrackAnimate(this.id); }); @@ -641,7 +679,7 @@ export class Animate implements IAnimate { this.onEnd(); this.status = AnimateStatus.END; const trackerTarget = this.target as any; - trackerTarget.restoreStaticAttribute(); + trackerTarget.restoreStaticAttribute(collectActiveSiblingAttrKeys(trackerTarget, this)); (this as any).__skipRestoreStaticAttributeOnRemove = true; return; } diff --git a/packages/vrender-animate/src/custom/static-truth.ts b/packages/vrender-animate/src/custom/static-truth.ts index c87a88d00..4256d6634 100644 --- a/packages/vrender-animate/src/custom/static-truth.ts +++ b/packages/vrender-animate/src/custom/static-truth.ts @@ -51,6 +51,11 @@ export function commitAnimationStaticAttrs( } (target as any).setFinalAttributes(commitAttrs); - target.setAttributes(commitAttrs as any, false, { type: AttributeUpdateType.ANIMATE_END }); + const commitStaticAttributes = (target as any)._commitAnimationStaticAttributes; + if (typeof commitStaticAttributes === 'function') { + commitStaticAttributes.call(target, commitAttrs, { type: AttributeUpdateType.ANIMATE_END }); + } else { + target.setAttributes(commitAttrs as any, false, { type: AttributeUpdateType.ANIMATE_END }); + } return true; } diff --git a/packages/vrender-animate/src/executor/animate-executor.ts b/packages/vrender-animate/src/executor/animate-executor.ts index b53cb622f..ee130619e 100644 --- a/packages/vrender-animate/src/executor/animate-executor.ts +++ b/packages/vrender-animate/src/executor/animate-executor.ts @@ -15,6 +15,7 @@ import type { } from './executor'; import { isArray, isFunction, isValidNumber } from '@visactor/vutils'; import { getCustomType } from './utils'; +import { commitAnimationStaticAttrs } from '../custom/static-truth'; interface IAnimateExecutor { execute: (params: IAnimationConfig) => void; @@ -452,6 +453,7 @@ export class AnimateExecutor implements IAnimateExecutor { type, graphic ); + this.commitAnimatedUpdateDiffAttrsOnEnd(animate, graphic, type, props); let totalDelay = 0; if (oneByOneDelay) { @@ -713,6 +715,51 @@ export class AnimateExecutor implements IAnimateExecutor { return type !== 'update'; } + private commitAnimatedUpdateDiffAttrsOnEnd( + animate: IAnimate, + graphic: IGraphic, + type: string, + props: Record | null + ): void { + if (type === 'update' || !props || !this.isUpdateDiffContext(graphic)) { + return; + } + + const keys = this.collectAnimatedUpdateDiffAttrKeys(graphic, props); + if (!keys?.length) { + return; + } + + animate.onEnd(() => { + commitAnimationStaticAttrs(graphic, keys, animate, props); + }); + } + + private isUpdateDiffContext(graphic: IGraphic): boolean { + const context = graphic.context as any; + return context?.diffState === 'update' || context?.animationState === 'update'; + } + + private collectAnimatedUpdateDiffAttrKeys(graphic: IGraphic, props: Record): string[] | null { + const diffAttrs = graphic.context?.diffAttrs; + if (!diffAttrs) { + return null; + } + + let keys: string[] | null = null; + for (const key in props) { + if ( + Object.prototype.hasOwnProperty.call(props, key) && + Object.prototype.hasOwnProperty.call(diffAttrs, key) && + diffAttrs[key] !== undefined + ) { + keys ?? (keys = []); + keys.push(key); + } + } + return keys; + } + /** * 创建自定义动画类 */ diff --git a/packages/vrender-core/__tests__/unit/entries/runtime-contribution-installer.test.ts b/packages/vrender-core/__tests__/unit/entries/runtime-contribution-installer.test.ts new file mode 100644 index 000000000..4dcb95127 --- /dev/null +++ b/packages/vrender-core/__tests__/unit/entries/runtime-contribution-installer.test.ts @@ -0,0 +1,182 @@ +/** + * @jest-environment node + */ + +import type { createLegacyBindingContext } from '../../../src/legacy/binding-context'; + +declare const require: any; + +describe('runtime contribution installer', () => { + beforeEach(() => { + jest.resetModules(); + }); + + function createMockApp() { + return { + registry: { + renderer: { + register: jest.fn(), + unregister: jest.fn() + }, + picker: { + register: jest.fn(), + unregister: jest.fn() + } + }, + context: { + registry: { + contribution: { + get: jest.fn(() => []), + register: jest.fn(), + unregister: jest.fn() + }, + plugin: {} + } + } + }; + } + + test('loads the same module once per binding context while allowing repeated app installation', () => { + jest.isolateModules(() => { + const { installRuntimeContributionModule } = + require('../../../src/entries/runtime-installer') as typeof import('../../../src/entries/runtime-installer'); + + const app = createMockApp(); + const module = { + registry: jest.fn() + }; + + installRuntimeContributionModule(module, { app: app as any, targets: ['graphic-renderer'] }); + const firstRegisterCount = app.registry.renderer.register.mock.calls.length; + + installRuntimeContributionModule(module, { app: app as any, targets: ['graphic-renderer'] }); + + expect(module.registry).toHaveBeenCalledTimes(2); + expect(firstRegisterCount).toBeGreaterThan(0); + expect(app.registry.renderer.register.mock.calls.length).toBeGreaterThan(firstRegisterCount); + }); + }); + + test('accepts function modules for runtime state before an app exists', () => { + jest.isolateModules(() => { + const { getRuntimeInstallerBindingContext, installRuntimeContributionModule } = + require('../../../src/entries/runtime-installer') as typeof import('../../../src/entries/runtime-installer'); + + const Token = Symbol('runtime-test-token'); + const module = jest.fn((context: ReturnType) => { + context.bind(Token).toConstantValue('installed'); + }); + + installRuntimeContributionModule(module); + + expect(module).toHaveBeenCalledTimes(2); + expect(getRuntimeInstallerBindingContext().getAll(Token)).toEqual(['installed']); + }); + }); + + test('refreshes renderer contribution providers after replacing a bound contribution token', () => { + jest.isolateModules(() => { + const { + getRuntimeInstallerBindingContext, + installRuntimeContributionModule, + installRuntimeGraphicRenderersToApp + } = require('../../../src/entries/runtime-installer') as typeof import('../../../src/entries/runtime-installer'); + const { BaseRenderContributionTime } = + require('../../../src/common/enums') as typeof import('../../../src/common/enums'); + const { rectModule } = + require('../../../src/render/contributions/render/rect-module') as typeof import('../../../src/render/contributions/render/rect-module'); + const { GraphicRender } = + require('../../../src/render/contributions/render/symbol') as typeof import('../../../src/render/contributions/render/symbol'); + const { RectRenderContribution } = + require('../../../src/render/contributions/render/contributions/constants') as typeof import('../../../src/render/contributions/render/contributions/constants'); + const { SplitRectAfterRenderContribution, SplitRectBeforeRenderContribution } = + require('../../../src/render/contributions/render/contributions/rect-contribution-render') as typeof import('../../../src/render/contributions/render/contributions/rect-contribution-render'); + const { createContributionProvider } = + require('../../../src/common/contribution-provider') as typeof import('../../../src/common/contribution-provider'); + + class VTableSplitRectBeforeRenderContribution { + time = BaseRenderContributionTime.beforeFillStroke; + useStyle = true; + order = 0; + drawShape(): void { + return undefined; + } + } + + class VTableSplitRectAfterRenderContribution { + time = BaseRenderContributionTime.afterFillStroke; + useStyle = true; + order = 0; + drawShape(): void { + return undefined; + } + } + + const rendererEntries = new Map(); + const app = { + registry: { + renderer: { + register: jest.fn((key: string, renderer: unknown) => { + rendererEntries.set(key, renderer); + }), + unregister: jest.fn((key: string) => { + rendererEntries.delete(key); + }), + getAll: jest.fn(() => Array.from(rendererEntries.values())) + }, + picker: { + register: jest.fn(), + unregister: jest.fn() + } + }, + context: { + registry: { + contribution: { + get: jest.fn(() => []), + register: jest.fn(), + unregister: jest.fn() + }, + plugin: {} + } + } + }; + const runtimeContext = getRuntimeInstallerBindingContext(); + rectModule({ bind: runtimeContext.bind }); + installRuntimeGraphicRenderersToApp(app as any); + const cachedRectRenderer = runtimeContext.getAll(GraphicRender).find(renderer => renderer.type === 'rect'); + + expect(cachedRectRenderer?._renderContribitions.map((item: unknown) => item.constructor.name)).toContain( + 'SplitRectAfterRenderContribution' + ); + createContributionProvider(RectRenderContribution, runtimeContext); + + const module = { + registry(bind: any, unbind: any, isBound: any, rebind: any) { + if (isBound(SplitRectBeforeRenderContribution)) { + rebind(SplitRectBeforeRenderContribution).to(VTableSplitRectBeforeRenderContribution).inSingletonScope(); + } else { + bind(VTableSplitRectBeforeRenderContribution).toSelf().inSingletonScope(); + bind(RectRenderContribution).toService(VTableSplitRectBeforeRenderContribution); + } + + if (isBound(SplitRectAfterRenderContribution)) { + rebind(SplitRectAfterRenderContribution).to(VTableSplitRectAfterRenderContribution).inSingletonScope(); + } else { + bind(VTableSplitRectAfterRenderContribution).toSelf().inSingletonScope(); + bind(RectRenderContribution).toService(VTableSplitRectAfterRenderContribution); + } + } + }; + + installRuntimeContributionModule(module, { app: app as any, targets: ['graphic-renderer'] }); + + const rectRenderer = (app.registry.renderer.getAll() as any[]).find(renderer => renderer.type === 'rect'); + const contributionNames = rectRenderer._renderContribitions.map((item: unknown) => item.constructor.name); + + expect(contributionNames).toContain('VTableSplitRectAfterRenderContribution'); + expect(contributionNames).toContain('VTableSplitRectBeforeRenderContribution'); + expect(contributionNames).not.toContain('SplitRectAfterRenderContribution'); + expect(contributionNames).not.toContain('SplitRectBeforeRenderContribution'); + }); + }); +}); diff --git a/packages/vrender-core/__tests__/unit/render/rect-split-stroke.test.ts b/packages/vrender-core/__tests__/unit/render/rect-split-stroke.test.ts new file mode 100644 index 000000000..262b8131e --- /dev/null +++ b/packages/vrender-core/__tests__/unit/render/rect-split-stroke.test.ts @@ -0,0 +1,74 @@ +import { + SplitRectAfterRenderContribution, + SplitRectBeforeRenderContribution +} from '../../../src/render/contributions/render/contributions/rect-contribution-render'; + +describe('rect split stroke contribution', () => { + test('treats null stroke array entries as disabled rect sides', () => { + const contribution = new SplitRectBeforeRenderContribution(); + const doFillOrStroke = { doFill: true, doStroke: true }; + const rect = { + attribute: { + stroke: [null, null, '#E1E4E8', null] + } + }; + + contribution.drawShape( + rect as any, + {} as any, + 0, + 0, + true, + true, + true, + true, + { stroke: false } as any, + {} as any, + undefined, + undefined, + doFillOrStroke + ); + + expect(doFillOrStroke.doStroke).toBe(false); + }); + + test('draws only truthy rect sides for null split stroke arrays', () => { + const contribution = new SplitRectAfterRenderContribution(); + const rect = { + attribute: { + x: 10, + y: 20, + width: 100, + height: 40, + stroke: [null, null, '#E1E4E8', null], + cornerRadius: 0, + cornerType: 'round' + } + }; + const context = { + lineWidth: 2, + setStrokeStyle: jest.fn(), + beginPath: jest.fn(), + moveTo: jest.fn(), + lineTo: jest.fn(), + stroke: jest.fn() + }; + + contribution.drawShape( + rect as any, + context as any, + 10, + 20, + true, + true, + true, + true, + { x: 0, y: 0, stroke: false, cornerRadius: 0, cornerType: 'round' } as any, + {} as any + ); + + expect(context.lineTo).toHaveBeenCalledTimes(1); + expect(context.lineTo).toHaveBeenCalledWith(10, 60); + expect(context.stroke).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/vrender-core/src/common/contribution-provider.ts b/packages/vrender-core/src/common/contribution-provider.ts index a17c3a70e..7dd70d42e 100644 --- a/packages/vrender-core/src/common/contribution-provider.ts +++ b/packages/vrender-core/src/common/contribution-provider.ts @@ -63,19 +63,26 @@ export function bindContributionProviderNoSingletonScope(bind: any, id: ServiceI } export class ContributionStore { - static store: Map, ContributionProviderCache> = new Map(); + static store: Map, Set>> = new Map(); - static getStore(id: ServiceIdentifier): ContributionProviderCache { - return this.store.get(id); + static getStore(id: ServiceIdentifier): ContributionProviderCache | undefined { + return this.store.get(id)?.values().next().value; } static setStore(id: ServiceIdentifier, cache: ContributionProviderCache): void { - this.store.set(id, cache); + let caches = this.store.get(id); + if (!caches) { + caches = new Set>(); + this.store.set(id, caches); + } + caches.add(cache); } static refreshAllContributions(): void { - this.store.forEach(cache => { - cache.refresh(); + this.store.forEach(caches => { + caches.forEach(cache => { + cache.refresh(); + }); }); } } diff --git a/packages/vrender-core/src/entries/runtime-installer.ts b/packages/vrender-core/src/entries/runtime-installer.ts index a59e7c9ad..6300fb00f 100644 --- a/packages/vrender-core/src/entries/runtime-installer.ts +++ b/packages/vrender-core/src/entries/runtime-installer.ts @@ -19,6 +19,7 @@ import { WindowHandlerContribution, DefaultWindow } from '../core/window'; import coreModule from '../core/core-modules'; import graphicModule from '../graphic/graphic-service/graphic-module'; import { createLegacyBindingContext, type ILegacyBindingContext } from '../legacy/binding-context'; +import { getLegacyBindingContext, preLoadAllModule } from '../legacy/bootstrap'; import pickModule from '../picker/pick-modules'; import { AreaRenderContribution } from '../render/contributions/render/contributions/constants'; import { DrawItemInterceptor } from '../render/contributions/render/draw-interceptor'; @@ -31,6 +32,32 @@ import loadRenderContributions from '../render/contributions/modules'; import { AutoEnablePlugins } from '../plugins/constants'; import { DefaultPluginService } from '../plugins/plugin-service'; +export type TRuntimeContributionModuleRegistry = ( + bind: ILegacyBindingContext['bind'], + unbind: (serviceIdentifier: ServiceIdentifier) => void, + isBound: ILegacyBindingContext['isBound'], + rebind: ILegacyBindingContext['rebind'] +) => void; + +export type TRuntimeContributionModule = + | ((context: ILegacyBindingContext) => void) + | { + registry: TRuntimeContributionModuleRegistry; + }; + +export type TRuntimeContributionInstallTarget = + | 'graphic-renderer' + | 'draw-contribution' + | { + picker: ServiceIdentifier; + }; + +export interface IRuntimeContributionModuleInstallOptions { + app?: IApp; + legacy?: boolean; + targets?: TRuntimeContributionInstallTarget[]; +} + const runtimeInstallerContext = createLegacyBindingContext(); let runtimeInstallerPreloaded = false; let runtimeGlobal: IGlobal | undefined; @@ -38,6 +65,9 @@ const RUNTIME_RENDERER_NAMESPACE = 'vrender:runtime-renderer'; const RUNTIME_PICKER_NAMESPACE = 'vrender:runtime-picker'; const runtimeEntryKeys = new WeakMap>>(); const runtimeDrawContributions = new WeakMap>(); +const loadedRuntimeContributionModules = new WeakMap>(); +const DEFAULT_RUNTIME_CONTRIBUTION_TARGETS: TRuntimeContributionInstallTarget[] = ['graphic-renderer']; +const noopUnbindRuntimeContributionService = (): void => undefined; function ensureRuntimeInstallerPreloaded(): void { if (runtimeInstallerPreloaded) { @@ -217,3 +247,95 @@ export function installRuntimePickersToApp( RUNTIME_PICKER_NAMESPACE ); } + +function getRuntimeContributionModuleIdentity(module: TRuntimeContributionModule): object { + return module as object; +} + +function hasLoadedRuntimeContributionModule( + context: ILegacyBindingContext, + module: TRuntimeContributionModule +): boolean { + return loadedRuntimeContributionModules.get(context)?.has(getRuntimeContributionModuleIdentity(module)) ?? false; +} + +function markRuntimeContributionModuleLoaded(context: ILegacyBindingContext, module: TRuntimeContributionModule): void { + let modules = loadedRuntimeContributionModules.get(context); + if (!modules) { + modules = new WeakSet(); + loadedRuntimeContributionModules.set(context, modules); + } + modules.add(getRuntimeContributionModuleIdentity(module)); +} + +function loadRuntimeContributionModuleToContext( + context: ILegacyBindingContext, + module: TRuntimeContributionModule +): void { + if (hasLoadedRuntimeContributionModule(context, module)) { + return; + } + + if (typeof module === 'function') { + module(context); + } else { + module.registry(context.bind, noopUnbindRuntimeContributionService, context.isBound, context.rebind); + } + + markRuntimeContributionModuleLoaded(context, module); +} + +function isRuntimePickerTarget( + target: TRuntimeContributionInstallTarget +): target is { picker: ServiceIdentifier } { + return typeof target === 'object' && target !== null && 'picker' in target; +} + +function installRuntimeContributionTargetsToApp( + app: IApp, + targets: TRuntimeContributionInstallTarget[] | undefined +): void { + const resolvedTargets = targets ?? DEFAULT_RUNTIME_CONTRIBUTION_TARGETS; + let installGraphicRenderers = false; + let installDrawContributions = false; + const pickerTargets = new Set>(); + + resolvedTargets.forEach(target => { + if (target === 'graphic-renderer') { + installGraphicRenderers = true; + } else if (target === 'draw-contribution') { + installDrawContributions = true; + } else if (isRuntimePickerTarget(target)) { + pickerTargets.add(target.picker); + } + }); + + if (installDrawContributions) { + installRuntimeDrawContributionsToApp(app); + } + if (installGraphicRenderers) { + installRuntimeGraphicRenderersToApp(app); + } + pickerTargets.forEach(serviceIdentifier => { + installRuntimePickersToApp(app, serviceIdentifier); + }); +} + +export function installRuntimeContributionModule( + module: TRuntimeContributionModule, + { app, legacy = true, targets }: IRuntimeContributionModuleInstallOptions = {} +): void { + const runtimeContext = getRuntimeInstallerBindingContext(); + loadRuntimeContributionModuleToContext(runtimeContext, module); + + if (legacy) { + preLoadAllModule(); + loadRuntimeContributionModuleToContext(getLegacyBindingContext(), module); + } + + refreshRuntimeInstallerContributions(); + + if (app) { + installRuntimeContributionTargetsToApp(app, targets); + } +} diff --git a/packages/vrender-core/src/graphic/graphic.ts b/packages/vrender-core/src/graphic/graphic.ts index 71255485f..4493b86a1 100644 --- a/packages/vrender-core/src/graphic/graphic.ts +++ b/packages/vrender-core/src/graphic/graphic.ts @@ -218,6 +218,8 @@ function areAttributeValuesEqual(left: unknown, right: unknown): boolean { return isEqual(left, right); } +type ExcludedAttributeKeys = Record; + function deepMergeAttributeValue(base: Record, value: Record): Record { const result: Record = cloneAttributeValue(base) ?? {}; @@ -771,11 +773,18 @@ export abstract class Graphic = Partial; } - protected syncObjectToSnapshot(target: Record, snapshot: Record): AttributeDelta { + protected syncObjectToSnapshot( + target: Record, + snapshot: Record, + excludedKeys?: ExcludedAttributeKeys + ): AttributeDelta { const delta: AttributeDelta = new Map(); const keySet = new Set([...Object.keys(target), ...Object.keys(snapshot)]); keySet.forEach(key => { + if (excludedKeys?.[key] === true) { + return; + } const hasNext = Object.prototype.hasOwnProperty.call(snapshot, key); const previousValue = target[key]; @@ -799,24 +808,24 @@ export abstract class Graphic = Partial; - const delta = this.syncObjectToSnapshot(this.attribute as Record, snapshot); + const delta = this.syncObjectToSnapshot(this.attribute as Record, snapshot, excludedKeys); this.valid = this.isValid(); - this.attributeMayContainTransientAttrs = false; + this.attributeMayContainTransientAttrs = !!excludedKeys; return delta; } - protected _syncFinalAttributeFromStaticTruth(): void { + protected _syncFinalAttributeFromStaticTruth(excludedKeys?: ExcludedAttributeKeys): void { const target = (this as any).finalAttribute; if (!target) { return; } const snapshot = this.buildStaticAttributeSnapshot() as Record; - this.syncObjectToSnapshot(target, snapshot); + this.syncObjectToSnapshot(target, snapshot, excludedKeys); } protected mergeAttributeDeltaCategory(category: UpdateCategory, key: string, prev: unknown, next: unknown) { @@ -989,6 +998,41 @@ export abstract class Graphic = Partial, context?: ISetAttributeContext): void { + if (!params) { + return; + } + + const source = params as Record; + const baseAttributes = this.getBaseAttributesStorage() as Record; + const target = this.attribute as Record; + const delta: AttributeDelta = new Map(); + let hasKeys = false; + + for (const key in source) { + if (!Object.prototype.hasOwnProperty.call(source, key)) { + continue; + } + hasKeys = true; + const previousValue = target[key]; + const nextValue = source[key]; + baseAttributes[key] = nextValue; + target[key] = nextValue; + if (!areAttributeValuesEqual(previousValue, nextValue)) { + delta.set(key, { prev: previousValue, next: nextValue }); + } + } + + if (!hasKeys) { + return; + } + + this.valid = this.isValid(); + this.attributeMayContainTransientAttrs = true; + this.submitUpdateByDelta(delta); + this.onAttributeUpdate(context); + } + applyAnimationTransientAttributes( params: Partial, forceUpdateTag: boolean = false, @@ -1050,9 +1094,12 @@ export abstract class Graphic = Partial s === false || s === null); +} + export class DefaultRectRenderContribution implements IRectRenderContribution { time: BaseRenderContributionTime = BaseRenderContributionTime.afterFillStroke; useStyle: boolean = true; @@ -149,8 +154,8 @@ export class SplitRectBeforeRenderContribution implements IRectRenderContributio ) { const { stroke = groupAttribute.stroke } = group.attribute as any; - // 数组且存在为false的项目,那就不绘制 - if (Array.isArray(stroke) && stroke.some(s => s === false)) { + // 数组且存在为false/null的项目,交给分边描边 contribution 处理 + if (hasDisabledStrokeSide(stroke)) { doFillOrStroke.doStroke = false; } } @@ -197,7 +202,7 @@ export class SplitRectAfterRenderContribution implements IRectRenderContribution height = (height ?? y1 - originY) || 0; // 不是数组 - if (!(Array.isArray(stroke) && stroke.some(s => s === false))) { + if (!hasDisabledStrokeSide(stroke)) { return; } diff --git a/packages/vrender/__tests__/unit/entries.test.ts b/packages/vrender/__tests__/unit/entries.test.ts index d4db494c5..5a331351b 100644 --- a/packages/vrender/__tests__/unit/entries.test.ts +++ b/packages/vrender/__tests__/unit/entries.test.ts @@ -467,7 +467,7 @@ describe('vrender app-scoped entries', () => { }); }); - test('bootstrapVRenderBrowserApp should merge legacy renderer and picker registrations into app registries', () => { + test('bootstrapVRenderBrowserApp should register legacy renderer and picker entries without clearing app registries', () => { jest.isolateModules(() => { const legacyRenderer = { numberType: 11, @@ -480,26 +480,15 @@ describe('vrender app-scoped entries', () => { type: 'rect', constructor: { name: 'DefaultCanvasRectPicker' } }; - const appRenderer = { - numberType: 5, - constructor: { name: 'DefaultCanvasGroupRender' }, - reInit: jest.fn() - }; - const appPicker = { - numberType: 5, - constructor: { name: 'DefaultCanvasGroupPicker' } - }; const rendererRegister = jest.fn(); const pickerRegister = jest.fn(); const app = { registry: { renderer: { - getAll: jest.fn(() => [appRenderer]), clear: jest.fn(), register: rendererRegister }, picker: { - getAll: jest.fn(() => [appPicker]), clear: jest.fn(), register: pickerRegister } @@ -589,28 +578,19 @@ describe('vrender app-scoped entries', () => { expect(installDefaultGraphicsToApp).toHaveBeenCalledWith(app); expect(installBrowserPickersToApp).toHaveBeenCalledWith(app); expect(registerRect).toHaveBeenCalledTimes(1); - expect(app.registry.renderer.clear).toHaveBeenCalledTimes(1); - expect(app.registry.picker.clear).toHaveBeenCalledTimes(1); - expect(rendererRegister).toHaveBeenCalledTimes(2); - expect(pickerRegister).toHaveBeenCalledTimes(2); - expect(rendererRegister).toHaveBeenCalledWith( - expect.stringContaining('renderer:5:unknown:DefaultCanvasGroupRender'), - appRenderer - ); + expect(app.registry.renderer.clear).not.toHaveBeenCalled(); + expect(app.registry.picker.clear).not.toHaveBeenCalled(); + expect(rendererRegister).toHaveBeenCalledTimes(1); + expect(pickerRegister).toHaveBeenCalledTimes(1); expect(rendererRegister).toHaveBeenCalledWith( expect.stringContaining('renderer:11:rect:DefaultCanvasRectRender'), legacyRenderer ); - expect(pickerRegister).toHaveBeenCalledWith( - expect.stringContaining('picker:5:unknown:DefaultCanvasGroupPicker'), - appPicker - ); expect(pickerRegister).toHaveBeenCalledWith( expect.stringContaining('picker:11:rect:DefaultCanvasRectPicker'), legacyPicker ); expect(legacyRenderer.reInit).toHaveBeenCalledTimes(1); - expect(appRenderer.reInit).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/vrender/__tests__/unit/public-subpath-exports.test.ts b/packages/vrender/__tests__/unit/public-subpath-exports.test.ts index 97a6a2bf6..fca60262e 100644 --- a/packages/vrender/__tests__/unit/public-subpath-exports.test.ts +++ b/packages/vrender/__tests__/unit/public-subpath-exports.test.ts @@ -64,7 +64,8 @@ const expectedExports: Record = { { subpath: './entries/shared', source: 'src/entries/shared.ts', browserSource: 'src/entries/shared-browser.ts' }, { subpath: './entries/browser', source: 'src/entries/browser.ts' }, { subpath: './entries/node', source: 'src/entries/node.ts' }, - { subpath: './entries/miniapp', source: 'src/entries/miniapp.ts' } + { subpath: './entries/miniapp', source: 'src/entries/miniapp.ts' }, + { subpath: './entries/runtime-contribution', source: 'src/entries/runtime-contribution.ts' } ] }; diff --git a/packages/vrender/__tests__/unit/runtime-contribution-installer.test.ts b/packages/vrender/__tests__/unit/runtime-contribution-installer.test.ts new file mode 100644 index 000000000..700bd0b65 --- /dev/null +++ b/packages/vrender/__tests__/unit/runtime-contribution-installer.test.ts @@ -0,0 +1,100 @@ +/** + * @jest-environment node + */ + +declare const require: any; +export {}; + +const SHARED_APP_REGISTRY_KEY = Symbol.for('visactor.vrender.sharedAppRegistry'); + +describe('vrender runtime contribution installer', () => { + beforeEach(() => { + jest.resetModules(); + delete (globalThis as any)[SHARED_APP_REGISTRY_KEY]; + }); + + test('installs module state and refreshes existing shared apps when no app is passed', () => { + jest.isolateModules(() => { + const installCoreModule = jest.fn(); + jest.doMock('@visactor/vrender-core/entries/runtime-installer', () => ({ + installRuntimeContributionModule: installCoreModule + })); + + const { acquireSharedApp } = require('../../src/entries/shared-registry'); + const firstApp = { id: 'first', released: false, release: jest.fn() }; + const secondApp = { id: 'second', released: false, release: jest.fn() }; + acquireSharedApp('browser', { key: 'first' }, () => firstApp); + acquireSharedApp('node', { key: 'second' }, () => secondApp); + + const { installRuntimeContributionModule } = require('../../src/entries/runtime-contribution'); + const module = { registry: jest.fn() }; + const targets = ['graphic-renderer', 'draw-contribution']; + + installRuntimeContributionModule(module, { targets }); + + expect(installCoreModule).toHaveBeenCalledTimes(2); + expect(installCoreModule).toHaveBeenNthCalledWith(1, module, { targets, app: firstApp }); + expect(installCoreModule).toHaveBeenNthCalledWith(2, module, { targets, app: secondApp }); + }); + }); + + test('installs only the explicit app when app is passed', () => { + jest.isolateModules(() => { + const installCoreModule = jest.fn(); + jest.doMock('@visactor/vrender-core/entries/runtime-installer', () => ({ + installRuntimeContributionModule: installCoreModule + })); + + const { acquireSharedApp } = require('../../src/entries/shared-registry'); + const sharedApp = { id: 'shared', released: false, release: jest.fn() }; + acquireSharedApp('browser', { key: 'shared' }, () => sharedApp); + + const { installRuntimeContributionModule } = require('../../src/entries/runtime-contribution'); + const app = { id: 'explicit', released: false, release: jest.fn() }; + const module = { registry: jest.fn() }; + + installRuntimeContributionModule(module, { app, targets: ['graphic-renderer'] }); + + expect(installCoreModule).toHaveBeenCalledTimes(1); + expect(installCoreModule).toHaveBeenNthCalledWith(1, module, { app, targets: ['graphic-renderer'] }); + }); + }); + + test('defers app-less modules until future app creators finish default bootstrap', () => { + jest.isolateModules(() => { + const calls: string[] = []; + const app = { id: 'future-app', released: false, release: jest.fn() }; + const createBrowserApp = jest.fn(() => app); + const installCoreModule = jest.fn(() => { + calls.push('install-pending'); + }); + const bootstrapVRenderBrowserApp = jest.fn((target: unknown) => { + calls.push('bootstrap'); + return target; + }); + + jest.doMock('@visactor/vrender-core/entries/browser', () => ({ + createBrowserApp + })); + jest.doMock('@visactor/vrender-core/entries/runtime-installer', () => ({ + installRuntimeContributionModule: installCoreModule + })); + jest.doMock('../../src/entries/bootstrap', () => ({ + bootstrapVRenderBrowserApp + })); + + const { installRuntimeContributionModule } = require('../../src/entries/runtime-contribution'); + const module = { registry: jest.fn() }; + + installRuntimeContributionModule(module, { targets: ['graphic-renderer'] }); + expect(installCoreModule).not.toHaveBeenCalled(); + + const { createBrowserVRenderApp } = require('../../src/entries/browser'); + expect(createBrowserVRenderApp()).toBe(app); + + expect(installCoreModule).toHaveBeenCalledTimes(1); + expect(installCoreModule).toHaveBeenCalledWith(module, { targets: ['graphic-renderer'], app }); + expect(calls).toEqual(['bootstrap', 'install-pending']); + }); + }); +}); diff --git a/packages/vrender/package.json b/packages/vrender/package.json index 40820e7ec..e2875c384 100644 --- a/packages/vrender/package.json +++ b/packages/vrender/package.json @@ -97,6 +97,11 @@ "import": "./es/entries/miniapp.js", "require": "./cjs/entries/miniapp.js" }, + "./entries/runtime-contribution": { + "types": "./es/entries/runtime-contribution.d.ts", + "import": "./es/entries/runtime-contribution.js", + "require": "./cjs/entries/runtime-contribution.js" + }, "./entries/node": { "types": "./es/entries/node.d.ts", "import": "./es/entries/node.js", diff --git a/packages/vrender/src/entries/browser.ts b/packages/vrender/src/entries/browser.ts index da49b0c0e..81a6f3a89 100644 --- a/packages/vrender/src/entries/browser.ts +++ b/packages/vrender/src/entries/browser.ts @@ -1,6 +1,7 @@ import type { IApp, IEntryOptions, IEnvParamsMap } from '@visactor/vrender-core'; import { createBrowserApp } from '@visactor/vrender-core/entries/browser'; import { bootstrapVRenderBrowserApp } from './bootstrap'; +import { installPendingRuntimeContributionModulesToApp } from './runtime-contribution'; export type TVRenderBrowserAppEntryOptions = IEntryOptions & { envParams?: IEnvParamsMap['browser']; @@ -8,6 +9,8 @@ export type TVRenderBrowserAppEntryOptions = IEntryOptions & { export function createBrowserVRenderApp(options: TVRenderBrowserAppEntryOptions = {}): IApp { const { envParams, ...entryOptions } = options; + const app = bootstrapVRenderBrowserApp(createBrowserApp(entryOptions as any) as unknown as IApp, envParams); - return bootstrapVRenderBrowserApp(createBrowserApp(entryOptions as any) as unknown as IApp, envParams); + installPendingRuntimeContributionModulesToApp(app); + return app; } diff --git a/packages/vrender/src/entries/miniapp.ts b/packages/vrender/src/entries/miniapp.ts index d2b582dc0..fadcef6ac 100644 --- a/packages/vrender/src/entries/miniapp.ts +++ b/packages/vrender/src/entries/miniapp.ts @@ -1,6 +1,7 @@ import * as VRenderCore from '@visactor/vrender-core'; import type { IApp, IEntryOptions, IEnvParamsMap } from '@visactor/vrender-core'; import { bootstrapVRenderMiniApp } from './bootstrap'; +import { installPendingRuntimeContributionModulesToApp } from './runtime-contribution'; type TAppScopedMiniEnv = 'taro' | 'feishu' | 'tt' | 'wx' | 'lynx' | 'harmony'; @@ -17,8 +18,10 @@ function createMiniEnvVRenderApp( options: TVRenderMiniAppEntryOptions = {} ): IApp { const { envParams, ...entryOptions } = options; + const app = bootstrapVRenderMiniApp(createMiniappApp(entryOptions), env, envParams); - return bootstrapVRenderMiniApp(createMiniappApp(entryOptions), env, envParams); + installPendingRuntimeContributionModulesToApp(app); + return app; } export function createTaroVRenderApp(options: TVRenderMiniAppEntryOptions<'taro'> = {}): IApp { diff --git a/packages/vrender/src/entries/node.ts b/packages/vrender/src/entries/node.ts index 70c17a2fb..fbf675dbe 100644 --- a/packages/vrender/src/entries/node.ts +++ b/packages/vrender/src/entries/node.ts @@ -1,6 +1,7 @@ import * as VRenderCore from '@visactor/vrender-core'; import type { IApp, IEntryOptions, IEnvParamsMap } from '@visactor/vrender-core'; import { bootstrapVRenderNodeApp } from './bootstrap'; +import { installPendingRuntimeContributionModulesToApp } from './runtime-contribution'; export type TVRenderNodeAppEntryOptions = IEntryOptions & { /** @@ -16,6 +17,8 @@ const { createNodeApp } = VRenderCore as typeof VRenderCore & { export function createNodeVRenderApp(options: TVRenderNodeAppEntryOptions = {}): IApp { const { envParams, ...entryOptions } = options; + const app = bootstrapVRenderNodeApp(createNodeApp(entryOptions), envParams); - return bootstrapVRenderNodeApp(createNodeApp(entryOptions), envParams); + installPendingRuntimeContributionModulesToApp(app); + return app; } diff --git a/packages/vrender/src/entries/runtime-contribution.ts b/packages/vrender/src/entries/runtime-contribution.ts new file mode 100644 index 000000000..b91fbe846 --- /dev/null +++ b/packages/vrender/src/entries/runtime-contribution.ts @@ -0,0 +1,107 @@ +import type { IApp, IGraphicPicker, ILegacyBindingContext, ServiceIdentifier } from '@visactor/vrender-core'; +import * as runtimeInstaller from '@visactor/vrender-core/entries/runtime-installer'; +import { getAllSharedApps } from './shared-registry'; + +export type TRuntimeContributionModuleRegistry = ( + bind: ILegacyBindingContext['bind'], + unbind: (serviceIdentifier: ServiceIdentifier) => void, + isBound: ILegacyBindingContext['isBound'], + rebind: ILegacyBindingContext['rebind'] +) => void; + +export type TRuntimeContributionModule = + | ((context: ILegacyBindingContext) => void) + | { + registry: TRuntimeContributionModuleRegistry; + }; + +export type TRuntimeContributionInstallTarget = + | 'graphic-renderer' + | 'draw-contribution' + | { + picker: ServiceIdentifier; + }; + +export interface IRuntimeContributionModuleInstallOptions { + app?: IApp; + legacy?: boolean; + targets?: TRuntimeContributionInstallTarget[]; +} + +export interface ISharedRuntimeContributionModuleInstallOptions + extends Omit { + app?: IApp; + installExistingSharedApps?: boolean; +} + +type TCoreRuntimeContributionModuleInstaller = ( + module: TRuntimeContributionModule, + options?: IRuntimeContributionModuleInstallOptions +) => void; + +type TPendingRuntimeContributionModule = { + module: TRuntimeContributionModule; + options: Omit; +}; + +const installCoreRuntimeContributionModule = ( + runtimeInstaller as unknown as { + installRuntimeContributionModule: TCoreRuntimeContributionModuleInstaller; + } +).installRuntimeContributionModule; +const pendingRuntimeContributionModules: TPendingRuntimeContributionModule[] = []; +const pendingRuntimeContributionModuleMap = new WeakMap(); + +function getRuntimeContributionModuleIdentity(module: TRuntimeContributionModule): object { + return module as object; +} + +function addPendingRuntimeContributionModule( + module: TRuntimeContributionModule, + options: Omit +): void { + const identity = getRuntimeContributionModuleIdentity(module); + const existing = pendingRuntimeContributionModuleMap.get(identity); + + if (existing) { + existing.options = options; + return; + } + + const entry = { module, options }; + pendingRuntimeContributionModuleMap.set(identity, entry); + pendingRuntimeContributionModules.push(entry); +} + +export function installPendingRuntimeContributionModulesToApp(app: IApp): void { + pendingRuntimeContributionModules.forEach(({ module, options }) => { + installCoreRuntimeContributionModule(module, { + ...options, + app + }); + }); +} + +export function installRuntimeContributionModule( + module: TRuntimeContributionModule, + { app, installExistingSharedApps = true, ...runtimeOptions }: ISharedRuntimeContributionModuleInstallOptions = {} +): void { + if (app) { + installCoreRuntimeContributionModule(module, { + ...runtimeOptions, + app + }); + return; + } + + addPendingRuntimeContributionModule(module, runtimeOptions); + + if (installExistingSharedApps) { + getAllSharedApps().forEach(targetApp => { + installCoreRuntimeContributionModule(module, { + ...runtimeOptions, + app: targetApp + }); + }); + } +} diff --git a/packages/vrender/src/entries/shared-browser-lite.ts b/packages/vrender/src/entries/shared-browser-lite.ts index 385e1c400..819a59ea5 100644 --- a/packages/vrender/src/entries/shared-browser-lite.ts +++ b/packages/vrender/src/entries/shared-browser-lite.ts @@ -1,6 +1,7 @@ import type { IApp, IEntryOptions, IEnvParamsMap } from '@visactor/vrender-core'; import { createBrowserApp } from '@visactor/vrender-core/entries/browser'; import { bootstrapVRenderSharedBrowserLiteApp } from './bootstrap-browser-lite'; +import { installPendingRuntimeContributionModulesToApp } from './runtime-contribution'; import { acquireSharedApp, getSharedApp, @@ -29,8 +30,10 @@ function createSharedBrowserLiteApp(options: TVRenderSharedBrowserLiteAppOptions delete entryOptions.env; delete entryOptions.envParams; delete entryOptions.key; + const app = bootstrapVRenderSharedBrowserLiteApp(createBrowserApp(entryOptions as any) as unknown as IApp, envParams); - return bootstrapVRenderSharedBrowserLiteApp(createBrowserApp(entryOptions as any) as unknown as IApp, envParams); + installPendingRuntimeContributionModulesToApp(app); + return app; } export function acquireSharedBrowserLiteVRenderApp( diff --git a/packages/vrender/src/entries/shared-browser.ts b/packages/vrender/src/entries/shared-browser.ts index 87fb28f6e..3817189e5 100644 --- a/packages/vrender/src/entries/shared-browser.ts +++ b/packages/vrender/src/entries/shared-browser.ts @@ -1,6 +1,7 @@ import type { IApp, IEntryOptions, IEnvParamsMap } from '@visactor/vrender-core'; import { createBrowserApp } from '@visactor/vrender-core/entries/browser'; import { bootstrapVRenderSharedBrowserApp } from './bootstrap-browser'; +import { installPendingRuntimeContributionModulesToApp } from './runtime-contribution'; import { acquireSharedApp, getSharedApp, @@ -29,8 +30,10 @@ function createSharedBrowserApp(options: TVRenderSharedBrowserAppOptions): IApp delete entryOptions.env; delete entryOptions.envParams; delete entryOptions.key; + const app = bootstrapVRenderSharedBrowserApp(createBrowserApp(entryOptions as any) as unknown as IApp, envParams); - return bootstrapVRenderSharedBrowserApp(createBrowserApp(entryOptions as any) as unknown as IApp, envParams); + installPendingRuntimeContributionModulesToApp(app); + return app; } export function acquireSharedBrowserVRenderApp( diff --git a/packages/vrender/src/entries/shared-registry.ts b/packages/vrender/src/entries/shared-registry.ts index 6ad2c892f..6e1445d64 100644 --- a/packages/vrender/src/entries/shared-registry.ts +++ b/packages/vrender/src/entries/shared-registry.ts @@ -162,6 +162,24 @@ export function getSharedApp( return record.app; } +export function getAllSharedApps(): IApp[] { + const apps: IApp[] = []; + const seen = new Set(); + const registry = getSharedAppRegistry(); + + registry.forEach(envRegistry => { + envRegistry.forEach(record => { + if (record.released || record.app.released || seen.has(record.app)) { + return; + } + seen.add(record.app); + apps.push(record.app); + }); + }); + + return apps; +} + export function releaseSharedApp( env: TEnv, key: TVRenderSharedAppKey = DEFAULT_SHARED_APP_KEY