diff --git a/packages/angular-table/package.json b/packages/angular-table/package.json index 8cfab3718e..e1756f47eb 100644 --- a/packages/angular-table/package.json +++ b/packages/angular-table/package.json @@ -56,6 +56,7 @@ "scripts": { "build": "ng-packagr -p ng-package.json -c tsconfig.build.json && rimraf ./dist/package.json && find dist -name '*.map' -delete", "build:types": "tsc --emitDeclarationOnly", + "bench:flex-render": "vitest bench --run tests/flex-render/flex-render.bench.ts", "clean": "rimraf ./build && rimraf ./dist", "test:build": "publint --strict", "test:eslint": "eslint ./src", diff --git a/packages/angular-table/src/flex-render/flags.ts b/packages/angular-table/src/flex-render/flags.ts deleted file mode 100644 index e265c847c8..0000000000 --- a/packages/angular-table/src/flex-render/flags.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Flags used to manage and optimize the rendering lifecycle of the content of the cell - * while using {@link FlexViewRenderer}. - */ -export const FlexRenderFlags = { - /** - * Indicates that the view is being created for the first time or will be cleared during the next update phase. - * This is the initial state and will transition after the first ngDoCheck. - */ - ViewFirstRender: 1 << 0, - /** - * Indicates the `content` property has been modified or the view requires a complete re-render. - * When this flag is enabled, the view will be cleared and recreated from scratch. - */ - ContentChanged: 1 << 1, - /** - * Indicates that the `props` property reference has changed. - * When this flag is enabled, the view context is updated based on the type of the content. - * - * For Component view, inputs will be updated and view will be marked as dirty. - * For TemplateRef and primitive values, view will be marked as dirty - */ - PropsReferenceChanged: 1 << 2, - /** - * Indicates that the current rendered view needs to be checked for changes. - * This will be set to true when `content(props)` result has changed or during - * forced update - */ - Dirty: 1 << 3, - /** - * Indicates that the first render effect has been checked at least one time. - */ - RenderEffectChecked: 1 << 4, -} as const diff --git a/packages/angular-table/src/flex-render/flexRenderComponent.ts b/packages/angular-table/src/flex-render/flexRenderComponent.ts index 5dc958c352..113c4596e3 100644 --- a/packages/angular-table/src/flex-render/flexRenderComponent.ts +++ b/packages/angular-table/src/flex-render/flexRenderComponent.ts @@ -13,15 +13,50 @@ type CreateComponentOptions = Parameters[1] type CreateComponentBindings = CreateComponentOptions['bindings'] type CreateComponentDirectives = CreateComponentOptions['directives'] +interface FlexRenderComponentMetadata { + mirror: ComponentMirror + allowedInputNames: Array + allowedOutputNames: Array +} + +const componentMetadataCache = new WeakMap< + Type, + FlexRenderComponentMetadata +>() + interface FlexRenderOptions< TInputs extends Record, TOutputs extends Record, > { + /** + * Optional identity used to control component instance reuse. + * + * A rendered component is reused while both its component type and key are + * unchanged. Change the key to explicitly destroy and recreate the component, + * for example when new creation-time bindings, directives, or an injector + * need to be applied. + * + * Inputs and outputs do not affect component identity and are synchronized + * onto a reused component instance. + * + * @example + * ```ts + * flexRenderComponent(EditorComponent, { + * key: row.original.editorVersion, + * inputs: { value: row.original.value }, + * }) + * ``` + */ + readonly key?: string | number /** * Native Angular bindings applied at component creation time via `createComponent`. * Use this option to set inputs, outputs, or two-way bindings at creation time. * Shouldn't be used together with {@link FlexRenderOptions#inputs} or {@link FlexRenderOptions#outputs} option. * + * Bindings are creation-time configuration. Changing this array after the + * component has mounted does not update the existing component. Change + * {@link FlexRenderOptions#key} to recreate the component with new bindings. + * * Binding input/outputs at creation time: {@link https://angular.dev/guide/components/programmatic-rendering#binding-inputs-outputs-and-setting-host-directives-at-creation} * * Two-way binding: {@link https://angular.dev/api/core/twoWayBinding} @@ -54,6 +89,10 @@ interface FlexRenderOptions< /** * Directives to apply to the component at creation time. * + * Directives are creation-time configuration. Changing this array after the + * component has mounted does not update the existing component. Change + * {@link FlexRenderOptions#key} to recreate the component with new directives. + * * Binding directives at creation time: {@link https://angular.dev/guide/components/programmatic-rendering#binding-inputs-outputs-and-setting-host-directives-at-creation} * * Two-way binding: {@link https://angular.dev/api/core/twoWayBinding} @@ -101,7 +140,11 @@ interface FlexRenderOptions< */ readonly outputs?: TOutputs /** - * Optional {@link Injector} that will be used when rendering the component + * Optional {@link Injector} that will be used when rendering the component. + * + * The injector is applied when the component is created. Change + * {@link FlexRenderOptions#key} to recreate a mounted component with a + * different injector. */ readonly injector?: Injector } @@ -151,7 +194,7 @@ export function flexRenderComponent( component: Type, options?: FlexRenderOptions, Outputs>, ): FlexRenderComponent { - const { inputs, injector, outputs, directives, bindings } = options ?? {} + const { key, inputs, injector, outputs, directives, bindings } = options ?? {} return new FlexRenderComponentInstance( component, inputs, @@ -159,6 +202,7 @@ export function flexRenderComponent( outputs, directives, bindings, + key, ) } @@ -207,6 +251,13 @@ export interface FlexRenderComponent { * The component type */ readonly component: Type + /** + * Optional identity used together with the component type to decide whether + * an existing component instance can be reused. + * + * @see {@link FlexRenderOptions#key} + */ + readonly key?: string | number /** * Reflected metadata about the component. */ @@ -260,8 +311,8 @@ export class FlexRenderComponentInstance< TComponent = any, > implements FlexRenderComponent { readonly mirror: ComponentMirror - readonly allowedInputNames: Array = [] - readonly allowedOutputNames: Array = [] + readonly allowedInputNames: Array + readonly allowedOutputNames: Array constructor( readonly component: Type, @@ -270,19 +321,26 @@ export class FlexRenderComponentInstance< readonly outputs?: Outputs, readonly directives?: CreateComponentDirectives, readonly bindings?: CreateComponentBindings, + readonly key?: string | number, ) { - const mirror = reflectComponentType(component) - if (!mirror) { - throw new Error( - `[@tanstack-table/angular] The provided symbol is not a component`, - ) - } - this.mirror = mirror - for (const input of this.mirror.inputs) { - this.allowedInputNames.push(input.propName) - } - for (const output of this.mirror.outputs) { - this.allowedOutputNames.push(output.propName) + let metadata = componentMetadataCache.get(component) as + FlexRenderComponentMetadata | undefined + if (!metadata) { + const mirror = reflectComponentType(component) + if (!mirror) { + throw new Error( + `[@tanstack-table/angular] The provided symbol is not a component`, + ) + } + metadata = { + mirror, + allowedInputNames: mirror.inputs.map((input) => input.propName), + allowedOutputNames: mirror.outputs.map((output) => output.propName), + } + componentMetadataCache.set(component, metadata) } + this.mirror = metadata.mirror + this.allowedInputNames = metadata.allowedInputNames + this.allowedOutputNames = metadata.allowedOutputNames } } diff --git a/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts b/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts index 0cd39c79ea..ad93672e46 100644 --- a/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts +++ b/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts @@ -3,13 +3,33 @@ import { ComponentRef, Injectable, Injector, - KeyValueDiffer, - KeyValueDiffers, OutputEmitterRef, OutputRefSubscription, ViewContainerRef, } from '@angular/core' import { FlexRenderComponent } from './flexRenderComponent' +import type { Type } from '@angular/core' + +const inputNameCache = new WeakMap, Map>() +const hasOwn = (value: object, key: PropertyKey): boolean => + Object.prototype.hasOwnProperty.call(value, key) + +function getInputName( + componentData: FlexRenderComponent, + propName: string, +): string | undefined { + let names = inputNameCache.get(componentData.component) + if (!names) { + names = new Map( + componentData.mirror.inputs.map((input) => [ + input.propName, + input.templateName, + ]), + ) + inputNameCache.set(componentData.component, names) + } + return names.get(propName) +} /** * Creates and manages Angular component instances used by flex-rendered table @@ -32,7 +52,7 @@ export class FlexRenderComponentFactory { { injector: componentInjector, directives: flexRenderComponent.directives, - bindings: flexRenderComponent.bindings ?? [], + bindings: flexRenderComponent.bindings, }, ) const view = new FlexRenderComponentRef( @@ -57,10 +77,9 @@ export class FlexRenderComponentFactory { * be reused instead of recreated on every cell/header render. */ export class FlexRenderComponentRef { - readonly #keyValueDiffersFactory: KeyValueDiffers #componentData: FlexRenderComponent - #inputValueDiffer: KeyValueDiffer - + readonly #inputValues: Record = {} + readonly #creationKey: FlexRenderComponent['key'] readonly #outputRegistry: FlexRenderComponentOutputManager constructor( @@ -69,17 +88,8 @@ export class FlexRenderComponentRef { readonly componentInjector: Injector, ) { this.#componentData = componentData - this.#keyValueDiffersFactory = componentInjector.get(KeyValueDiffers) - - this.#outputRegistry = new FlexRenderComponentOutputManager( - this.#keyValueDiffersFactory, - this.outputs, - ) - - this.#inputValueDiffer = this.#keyValueDiffersFactory - .find(this.inputs) - .create() - this.#inputValueDiffer.diff(this.inputs) + this.#creationKey = componentData.key + this.#outputRegistry = new FlexRenderComponentOutputManager() this.componentRef.onDestroy(() => this.#outputRegistry.unsubscribeAll()) } @@ -96,15 +106,6 @@ export class FlexRenderComponentRef { return this.#componentData.outputs ?? {} } - /** - * Get component input and output diff by the given item - */ - diff(item: FlexRenderComponent) { - return { - inputDiff: this.#inputValueDiffer.diff(item.inputs ?? {}), - outputDiff: this.#outputRegistry.diff(item.outputs ?? {}), - } - } /** * * @param compare Whether the current ref component instance is the same as the given one @@ -113,37 +114,18 @@ export class FlexRenderComponentRef { return compare.component === this.component } + canReuse(compare: FlexRenderComponent): boolean { + return this.eqType(compare) && Object.is(compare.key, this.#creationKey) + } + /** * Tries to update current component refs input by the new given content component. */ - update(content: FlexRenderComponent) { - const eq = this.eqType(content) - if (!eq) return - const { inputDiff, outputDiff } = this.diff(content) - if (inputDiff) { - inputDiff.forEachAddedItem((item) => - this.setInput(item.key, item.currentValue), - ) - inputDiff.forEachChangedItem((item) => - this.setInput(item.key, item.currentValue), - ) - inputDiff.forEachRemovedItem((item) => this.setInput(item.key, undefined)) - } - if (outputDiff) { - outputDiff.forEachAddedItem((item) => { - this.setOutput(item.key, item.currentValue) - }) - outputDiff.forEachChangedItem((item) => { - if (item.currentValue) { - this.#outputRegistry.setListener(item.key, item.currentValue) - } else { - this.#outputRegistry.unsubscribe(item.key) - } - }) - outputDiff.forEachRemovedItem((item) => { - this.#outputRegistry.unsubscribe(item.key) - }) - } + update(content: FlexRenderComponent): void { + if (!this.canReuse(content)) return + + this.#syncInputs(content.inputs ?? {}) + this.#syncOutputs(content.outputs ?? {}) this.#componentData = content } @@ -154,13 +136,21 @@ export class FlexRenderComponentRef { setInputs(inputs: Record) { for (const prop in inputs) { - this.setInput(prop, inputs[prop]) + if (hasOwn(inputs, prop)) { + this.setInput(prop, inputs[prop]) + } } } + updateInputs(inputs: Record): void { + this.#syncInputs(inputs) + } + setInput(key: string, value: unknown) { - if (this.#componentData.allowedInputNames.includes(key)) { - this.componentRef.setInput(key, value) + const inputName = getInputName(this.#componentData, key) + if (inputName) { + this.componentRef.setInput(inputName, value) + this.#inputValues[key] = value } } @@ -172,7 +162,9 @@ export class FlexRenderComponentRef { ) { this.#outputRegistry.unsubscribeAll() for (const prop in outputs) { - this.setOutput(prop, outputs[prop]) + if (hasOwn(outputs, prop)) { + this.setOutput(prop, outputs[prop]) + } } } @@ -186,41 +178,70 @@ export class FlexRenderComponentRef { return } - const hasListener = this.#outputRegistry.hasListener(outputName) + const hasSubscription = this.#outputRegistry.hasSubscription(outputName) this.#outputRegistry.setListener(outputName, emit) - if (hasListener) { + if (hasSubscription) { return } const instance = this.componentRef.instance const output = instance[outputName as keyof typeof instance] if (output && output instanceof OutputEmitterRef) { - output.subscribe((value) => { - this.#outputRegistry.getListener(outputName)?.(value) - }) + this.#outputRegistry.setSubscription( + outputName, + output.subscribe((value) => { + this.#outputRegistry.getListener(outputName)?.(value) + }), + ) } } + + #syncInputs(inputs: Record): void { + for (const prop in inputs) { + if ( + hasOwn(inputs, prop) && + (!hasOwn(this.#inputValues, prop) || + !Object.is(this.#inputValues[prop], inputs[prop])) + ) { + this.setInput(prop, inputs[prop]) + } + } + for (const prop in this.#inputValues) { + if (!hasOwn(inputs, prop)) { + const inputName = getInputName(this.#componentData, prop) + if (inputName) { + this.componentRef.setInput(inputName, undefined) + } + delete this.#inputValues[prop] + } + } + } + + #syncOutputs( + outputs: Record< + string, + OutputEmitterRef['emit'] | null | undefined + >, + ): void { + for (const prop in outputs) { + if ( + hasOwn(outputs, prop) && + !Object.is(this.#outputRegistry.getListener(prop), outputs[prop]) + ) { + this.setOutput(prop, outputs[prop]) + } + } + this.#outputRegistry.unsubscribeMissing(outputs) + } } class FlexRenderComponentOutputManager { readonly #outputSubscribers: Record = {} readonly #outputListeners: Record) => void> = {} - readonly #valueDiffer: KeyValueDiffer< - string, - undefined | null | OutputEmitterRef['emit'] - > - - constructor(keyValueDiffers: KeyValueDiffers, initialOutputs: any) { - this.#valueDiffer = keyValueDiffers.find(initialOutputs).create() - if (initialOutputs) { - this.#valueDiffer.diff(initialOutputs) - } - } - - hasListener(outputName: string) { - return outputName in this.#outputListeners + hasSubscription(outputName: string) { + return outputName in this.#outputSubscribers } setListener(outputName: string, callback: (...args: Array) => void) { @@ -231,21 +252,30 @@ class FlexRenderComponentOutputManager { return this.#outputListeners[outputName] } + setSubscription( + outputName: string, + subscription: OutputRefSubscription, + ): void { + this.#outputSubscribers[outputName] = subscription + } + unsubscribeAll(): void { - for (const prop in this.#outputSubscribers) { + for (const prop in this.#outputListeners) { this.unsubscribe(prop) } } - unsubscribe(outputName: string) { - if (outputName in this.#outputSubscribers) { - this.#outputSubscribers[outputName]?.unsubscribe() - delete this.#outputSubscribers[outputName] - delete this.#outputListeners[outputName] + unsubscribeMissing(outputs: Record): void { + for (const prop in this.#outputListeners) { + if (!hasOwn(outputs, prop)) { + this.unsubscribe(prop) + } } } - diff(outputs: Record['emit'] | undefined>) { - return this.#valueDiffer.diff(outputs) + unsubscribe(outputName: string) { + this.#outputSubscribers[outputName]?.unsubscribe() + delete this.#outputSubscribers[outputName] + delete this.#outputListeners[outputName] } } diff --git a/packages/angular-table/src/flex-render/renderer.ts b/packages/angular-table/src/flex-render/renderer.ts index 20064719ff..bc56f1bb78 100644 --- a/packages/angular-table/src/flex-render/renderer.ts +++ b/packages/angular-table/src/flex-render/renderer.ts @@ -1,6 +1,5 @@ import { Injector, - computed, effect, runInInjectionContext, untracked, @@ -9,7 +8,6 @@ import { TanStackTableCellToken } from '../helpers/cell' import { TanStackTableHeaderToken } from '../helpers/header' import { TanStackTableToken } from '../helpers/table' import { FlexRenderComponentProps } from './context' -import { FlexRenderFlags } from './flags' import { flexRenderComponent } from './flexRenderComponent' import { FlexRenderComponentFactory } from './flexRenderComponentFactory' import { @@ -109,12 +107,12 @@ export class FlexViewRenderer< | CellContext | HeaderContext, > { - #renderFlags = FlexRenderFlags.ViewFirstRender #renderView: FlexRenderView< FlexRenderViewAllowedType, FlexRenderTypedContent > | null = null - #currentRenderEffectRef: EffectRef | null = null + #renderEffectRef: EffectRef | null = null + #previousProps: TProps | undefined #content: () => FlexRenderInputContent #props: () => TProps #injector: () => Injector @@ -122,21 +120,6 @@ export class FlexViewRenderer< #templateRef: TemplateRef #flexRenderComponentFactory: FlexRenderComponentFactory - readonly #getLatestContentValue = () => { - const content = this.#content() - const props = this.#props() - return typeof content !== 'function' - ? content - : runInInjectionContext(this.#injector(), () => content(props)) - } - - readonly #latestContent = computed(() => this.#getLatestContentValue()) - - #getContentValue = computed(() => { - const latestContent = this.#latestContent() - return mapToFlexRenderTypedContent(latestContent) - }) - constructor(options: RendererViewOptions) { this.#content = options.content this.#props = options.props @@ -149,180 +132,123 @@ export class FlexViewRenderer< } mount(): EffectRef { - let previousContent: FlexRenderInputContent - let previousProps: TProps - - return effect(() => { - const props = this.#props() - const content = this.#content() - - if (!(this.#renderFlags & FlexRenderFlags.ViewFirstRender)) { - if (previousContent !== content) { - this.#renderFlags |= FlexRenderFlags.ContentChanged - } - if (previousProps !== props) { - this.#renderFlags |= FlexRenderFlags.PropsReferenceChanged - } - } - - untracked(() => this.#update()) + if (this.#renderEffectRef) { + return this.#renderEffectRef + } - if (FlexRenderFlags.ViewFirstRender & this.#renderFlags) { - this.#renderFlags &= ~FlexRenderFlags.ViewFirstRender - } + this.#renderEffectRef = effect( + () => { + const content = this.#content() + const props = this.#props() + const injector = this.#injector() + const resolvedContent = + typeof content === 'function' + ? runInInjectionContext(injector, () => content(props)) + : content + + untracked(() => + this.#update( + mapToFlexRenderTypedContent(resolvedContent), + props, + injector, + ), + ) + }, + { injector: this.#viewContainerRef.injector }, + ) - previousContent = content - previousProps = props - }) + return this.#renderEffectRef } destroy(): void { - if (this.#currentRenderEffectRef) { - this.#currentRenderEffectRef.destroy() - this.#currentRenderEffectRef = null - } - if (this.#renderView) { - this.#renderView.unmount() - this.#renderView = null + if (this.#renderEffectRef) { + this.#renderEffectRef.destroy() + this.#renderEffectRef = null } + this.#destroyView() } - #update() { - if ( - this.#renderFlags & - (FlexRenderFlags.ContentChanged | FlexRenderFlags.ViewFirstRender) - ) { - this.#render() + #update( + content: FlexRenderTypedContent, + props: TProps, + injector: Injector, + ): void { + if (content.kind === 'null') { + this.#destroyView() + this.#previousProps = props return } - if (this.#renderFlags & FlexRenderFlags.PropsReferenceChanged) { - if (this.#renderView) this.#renderView.updateProps(this.#props()) - this.#renderFlags &= ~FlexRenderFlags.PropsReferenceChanged + const parentInjector = + content.kind === 'flexRenderComponent' + ? (content.content.injector ?? injector) + : injector + const renderView = this.#renderView + + if (!renderView || !renderView.eq(content)) { + this.#render(content, props, parentInjector) + return } - if (this.#renderFlags & FlexRenderFlags.Dirty) { - if (this.#renderView) this.#renderView.dirtyCheck() - this.#renderFlags &= ~FlexRenderFlags.Dirty + const propsChanged = this.#previousProps !== props + renderView.content = content + if (propsChanged) { + renderView.updateProps(props) } + renderView.dirtyCheck() + this.#previousProps = props } - #render() { - // When the view is recreated from scratch (content change or first render), - // we have to destroy the current effect listener since it will be recreated - // skipping the first call (FlexRenderFlags.RenderEffectChecked) - if (this.#shouldRecreateEntireView() && this.#currentRenderEffectRef) { - this.#currentRenderEffectRef.destroy() - this.#currentRenderEffectRef = null - this.#renderFlags &= ~FlexRenderFlags.RenderEffectChecked - } + #render( + content: Exclude, + props: TProps, + parentInjector: Injector, + ): void { + this.#destroyView() + this.#renderView = this.#renderViewByContent(content, props, parentInjector) + this.#previousProps = props + } - this.#viewContainerRef.clear() + #destroyView(): void { if (this.#renderView) { this.#renderView.unmount() this.#renderView = null } - - this.#renderFlags = - (this.#renderFlags & FlexRenderFlags.ViewFirstRender) | - (this.#renderFlags & FlexRenderFlags.RenderEffectChecked) - - const resolvedContent = this.#getContentValue() - this.#renderView = this.#renderViewByContent(resolvedContent) - // If the content is a function `content(props)`, we initialize an effect - // to react to changes. If the current fn uses signals, we will set the DirtySignal flag - // to re-schedule the component updates - if ( - !this.#currentRenderEffectRef && - typeof untracked(this.#content) === 'function' - ) { - this.#currentRenderEffectRef = effect( - () => { - this.#latestContent() - if (!(this.#renderFlags & FlexRenderFlags.RenderEffectChecked)) { - this.#renderFlags |= FlexRenderFlags.RenderEffectChecked - return - } - this.#renderFlags |= FlexRenderFlags.Dirty - this.#doCheck() - }, - { injector: this.#viewContainerRef.injector }, - ) - } - } - - #shouldRecreateEntireView() { - return ( - this.#renderFlags & - FlexRenderFlags.ContentChanged & - FlexRenderFlags.ViewFirstRender - ) - } - - #doCheck() { - const latestContent = this.#getContentValue() - if (latestContent.kind === 'null' || !this.#renderView) { - this.#renderFlags |= FlexRenderFlags.ContentChanged - } else { - const { kind: currentKind } = this.#renderView.content - if ( - latestContent.kind !== currentKind || - !this.#renderView.eq(latestContent) - ) { - this.#renderFlags |= FlexRenderFlags.ContentChanged - } - this.#renderView.content = latestContent - } - this.#update() } #renderViewByContent( - content: FlexRenderTypedContent, + content: Exclude, + props: TProps, + parentInjector: Injector, ): FlexRenderView | null { if (content.kind === 'primitive') { return this.#renderStringContent(content) } else if (content.kind === 'templateRef') { - return this.#renderTemplateRefContent(content) + return this.#renderTemplateRefContent(content, props, parentInjector) } else if (content.kind === 'flexRenderComponent') { - return this.#renderComponent(content) - } else if (content.kind === 'component') { - return this.#renderCustomComponent(content) - } else { - return null + return this.#renderComponent(content, parentInjector) } + return this.#renderCustomComponent(content, props, parentInjector) } #renderStringContent( template: Extract, ): FlexRenderTemplateView { - const context = () => { - const content = this.#content() - return typeof content === 'string' || typeof content === 'number' - ? content - : runInInjectionContext(this.#injector(), () => - content?.(this.#props()), - ) - } const ref = this.#viewContainerRef.createEmbeddedView(this.#templateRef, { - get $implicit() { - return context() - }, + $implicit: template.content, }) return new FlexRenderTemplateView(template, ref) } #renderTemplateRefContent( template: Extract, + props: TProps, + parentInjector: Injector, ): FlexRenderTemplateView { - const latestContext = () => this.#props() const view = this.#viewContainerRef.createEmbeddedView( template.content, - { - get $implicit() { - return latestContext() - }, - }, - { injector: this.#getInjector() }, + { $implicit: props }, + { injector: this.#getInjector(parentInjector) }, ) return new FlexRenderTemplateView(template, view) } @@ -332,9 +258,9 @@ export class FlexViewRenderer< FlexRenderTypedContent, { kind: 'flexRenderComponent' } >, + parentInjector: Injector, ): FlexRenderComponentView { - const { injector } = flexRenderComponent.content - const componentInjector = this.#getInjector(injector) + const componentInjector = this.#getInjector(parentInjector) const view = this.#flexRenderComponentFactory.createComponent( flexRenderComponent.content, componentInjector, @@ -344,11 +270,13 @@ export class FlexViewRenderer< #renderCustomComponent( component: Extract, + props: TProps, + parentInjector: Injector, ): FlexRenderComponentView { const instance = flexRenderComponent(component.content, { - inputs: this.#props(), + inputs: props, }) - const injector = this.#getInjector(instance.injector) + const injector = this.#getInjector(parentInjector) const view = this.#flexRenderComponentFactory.createComponent( instance, injector, @@ -356,7 +284,7 @@ export class FlexViewRenderer< return new FlexRenderComponentView(component, view) } - #getInjector(parentInjector?: Injector) { + #getInjector(parentInjector: Injector) { const getContext = () => this.#props() const proxy = new Proxy(this.#props(), { get: (_, key) => getContext()[key as keyof typeof _], @@ -383,7 +311,7 @@ export class FlexViewRenderer< } return Injector.create({ - parent: parentInjector ?? this.#injector(), + parent: parentInjector, providers: [ ...staticProviders, { provide: FlexRenderComponentProps, useValue: proxy }, diff --git a/packages/angular-table/src/flex-render/view.ts b/packages/angular-table/src/flex-render/view.ts index 39e93c2675..26fae5b640 100644 --- a/packages/angular-table/src/flex-render/view.ts +++ b/packages/angular-table/src/flex-render/view.ts @@ -78,8 +78,6 @@ export abstract class FlexRenderView< abstract dirtyCheck(): void - abstract onDestroy(callback: Function): void - abstract eq(view: TContent): boolean abstract unmount(): void @@ -106,26 +104,30 @@ export class FlexRenderTemplateView extends FlexRenderView< } override updateProps(_props: Record) { - this.view.markForCheck() + if (this.content.kind === 'templateRef') { + const context = this.view.context as { $implicit: unknown } + context.$implicit = _props + this.view.markForCheck() + } } override dirtyCheck() { - // Basically a no-op. When the view is created via EmbeddedViewRef, we don't need to do any manual update - // since this type of content has a proxy as a context, then every time the root component is checked for changes, - // the property getter will be re-evaluated. - // - // If in a future we need to manually mark the view as dirty, just uncomment next line - // this.view.markForCheck() + if (this.content.kind !== 'primitive') return + + const context = this.view.context as { $implicit: unknown } + context.$implicit = this.content.content + if ( + this.previousContent.kind !== 'primitive' || + !Object.is(this.previousContent.content, this.content.content) + ) { + this.view.markForCheck() + } } override unmount() { this.view.destroy() } - override onDestroy(callback: Function) { - this.view.onDestroy(callback) - } - override eq( compare: Extract< FlexRenderTypedContent, @@ -133,9 +135,7 @@ export class FlexRenderTemplateView extends FlexRenderView< >, ): boolean { return ( - (this.content.kind === 'primitive' && - compare.kind === 'primitive' && - this.content.content === compare.content) || + (this.content.kind === 'primitive' && compare.kind === 'primitive') || (this.content.kind === 'templateRef' && compare.kind === 'templateRef' && this.content.content === compare.content) @@ -166,12 +166,12 @@ export class FlexRenderComponentView extends FlexRenderView< override updateProps(props: Record) { switch (this.content.kind) { case 'component': { - this.view.setInputs(props) + this.view.updateInputs(props) break } case 'flexRenderComponent': { - // No-op. When FlexRenderFlags.PropsReferenceChanged is set, - // FlexRenderComponent will be updated into `dirtyCheck`. + // Wrapper inputs and outputs come from the newly resolved content and + // are synchronized in `dirtyCheck`. break } } @@ -187,8 +187,7 @@ export class FlexRenderComponentView extends FlexRenderView< break } case 'flexRenderComponent': { - // Given context instance will always have a different reference than the previous one, - // so instead of recreating the entire view, we will only update the current view + // Reuse the component and synchronize only changed inputs and outputs. if (this.view.eqType(this.content.content)) { this.view.update(this.content.content) } @@ -202,10 +201,6 @@ export class FlexRenderComponentView extends FlexRenderView< this.view.componentRef.destroy() } - override onDestroy(callback: Function) { - this.view.componentRef.onDestroy(callback) - } - override eq( compare: Extract< FlexRenderTypedContent, @@ -218,7 +213,7 @@ export class FlexRenderComponentView extends FlexRenderView< this.content.content === compare.content) || (this.content.kind === 'flexRenderComponent' && compare.kind === 'flexRenderComponent' && - this.content.content.component === compare.content.component) + this.view.canReuse(compare.content)) ) } } diff --git a/packages/angular-table/src/helpers/flexRenderCell.ts b/packages/angular-table/src/helpers/flexRenderCell.ts index 20f3a432f3..b96f02952d 100644 --- a/packages/angular-table/src/helpers/flexRenderCell.ts +++ b/packages/angular-table/src/helpers/flexRenderCell.ts @@ -130,12 +130,9 @@ export class FlexRenderCell< readonly #viewContainerRef = inject(ViewContainerRef) constructor() { - const content = computed(() => this.#renderData()[0]) - const props = computed(() => this.#renderData()[1]) - const renderer = new FlexViewRenderer({ - content: content, - props: props, + content: () => this.#renderData()[0], + props: () => this.#renderData()[1], injector: () => this.#injector, templateRef: this.#templateRef, viewContainerRef: this.#viewContainerRef, diff --git a/packages/angular-table/src/injectTable.ts b/packages/angular-table/src/injectTable.ts index 3486e93f7c..9cfeaca45a 100644 --- a/packages/angular-table/src/injectTable.ts +++ b/packages/angular-table/src/injectTable.ts @@ -99,14 +99,15 @@ export function injectTable< return ngZone.runOutsideAngular(() => lazyInit(() => { + const initialOptions = options() // Explicit type arguments skip generic inference from the spread object // (a type-check hot spot); the spread only adds the angular reactivity // binding to `features`. const table = constructTable({ - ...options(), + ...initialOptions, features: { coreReactivityFeature: angularReactivity(injector), - ...options().features, + ...initialOptions.features, }, }) diff --git a/packages/angular-table/src/reactivity.ts b/packages/angular-table/src/reactivity.ts index d35e0269ce..581e07f13e 100644 --- a/packages/angular-table/src/reactivity.ts +++ b/packages/angular-table/src/reactivity.ts @@ -21,9 +21,7 @@ function signalToReadonlyAtom( get: () => signal(), subscribe: (observer: Observer) => { return untracked(() => - toObservable(computed(signal), { injector: injector }).subscribe( - observer, - ), + toObservable(signal, { injector: injector }).subscribe(observer), ) }, }) @@ -47,9 +45,7 @@ function signalToWritableAtom( get: () => signal(), subscribe: (observer: Observer) => { return untracked(() => - toObservable(computed(signal), { injector: injector }).subscribe( - observer, - ), + toObservable(signal, { injector: injector }).subscribe(observer), ) }, }) diff --git a/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts b/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts index 698ec30ad7..638f90e400 100644 --- a/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts +++ b/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts @@ -12,4 +12,10 @@ test('Infer component inputs', () => { // Input is optional so we can skip passing the property flexRenderComponent(Test, { inputs: {} }) + + flexRenderComponent(Test, { key: 'stable-key' }) + flexRenderComponent(Test, { key: 1 }) + + // @ts-expect-error Keys must have stable primitive identity + flexRenderComponent(Test, { key: {} }) }) diff --git a/packages/angular-table/tests/flex-render/flex-render.bench.ts b/packages/angular-table/tests/flex-render/flex-render.bench.ts new file mode 100644 index 0000000000..76e8a02a1c --- /dev/null +++ b/packages/angular-table/tests/flex-render/flex-render.bench.ts @@ -0,0 +1,239 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, + signal, +} from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { bench, describe } from 'vitest' +import { + FlexRender, + flexRenderComponent, + injectTable, + stockFeatures, +} from '../../src' +import type { ColumnDef } from '../../src' + +const benchmarkOptions = { time: 2_000, warmupTime: 500 } + +@Component({ + template: ` + {{ tick() }} + @for (item of items; track item) { + + {{ value }} + + } + `, + imports: [FlexRender], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class PrimitiveTable { + readonly items = Array.from({ length: 500 }, (_, index) => index) + readonly value = signal('value') + readonly tick = signal(0) + readonly context = {} + readonly render = () => this.value() +} + +@Component({ + template: ``, +}) +class RenderedComponent {} + +describe('flexRender hot paths', () => { + const fixture = TestBed.createComponent(PrimitiveTable) + fixture.detectChanges() + + bench( + 'unrelated change detection for 500 primitive cells', + () => { + fixture.componentInstance.tick.update((value) => value + 1) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'create 500 component render descriptors', + () => { + for (let index = 0; index < 500; index++) { + flexRenderComponent(RenderedComponent) + } + }, + benchmarkOptions, + ) +}) + +interface BenchmarkRow { + id: string + values: Array +} + +const rowCount = 100 +const columnCount = 12 +const largeTableData: Array = Array.from( + { length: rowCount }, + (_, rowIndex) => ({ + id: `row-${rowIndex}`, + values: Array.from( + { length: columnCount }, + (_, columnIndex) => `${rowIndex}:${columnIndex}`, + ), + }), +) +const handleActivate = () => {} + +@Component({ + selector: 'benchmark-cell-a', + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class BenchmarkCellA { + readonly value = input.required() + readonly version = input.required() + readonly activate = output() +} + +@Component({ + selector: 'benchmark-cell-b', + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class BenchmarkCellB { + readonly value = input.required() + readonly version = input.required() + readonly activate = output() +} + +@Component({ + template: ` + {{ hostTick() }} + + + @for (row of table.getRowModel().rows; track row.id) { + + @for (cell of row.getVisibleCells(); track cell.id) { + + } + + } + +
+ + {{ value }} + +
+ `, + imports: [FlexRender], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class LargeMixedTable { + readonly hostTick = signal(0) + readonly valueVersion = signal(0) + readonly componentKind = signal<'a' | 'b'>('a') + readonly contentKind = signal<'primitive' | 'component'>('primitive') + + readonly columns: Array> = + Array.from({ length: columnCount }, (_, columnIndex) => ({ + id: `column-${columnIndex}`, + accessorFn: (row) => row.values[columnIndex]!, + cell: (context) => { + const value = context.getValue() + + // Four primitive columns whose values change in place. + if (columnIndex < 4) { + return `${value}:${this.valueVersion()}` + } + + // Four stable component columns whose inputs change frequently. + if (columnIndex < 8) { + const component = + columnIndex % 2 === 0 ? BenchmarkCellA : BenchmarkCellB + return flexRenderComponent(component, { + inputs: { value, version: this.valueVersion() }, + outputs: { activate: handleActivate }, + }) + } + + // Two columns that intentionally replace component A with component B. + if (columnIndex < 10) { + const component = + this.componentKind() === 'a' ? BenchmarkCellA : BenchmarkCellB + return flexRenderComponent(component, { + inputs: { value, version: 0 }, + outputs: { activate: handleActivate }, + }) + } + + // Two columns that cross the primitive/component view boundary. + return this.contentKind() === 'primitive' + ? value + : flexRenderComponent(BenchmarkCellA, { + inputs: { value, version: 0 }, + outputs: { activate: handleActivate }, + }) + }, + })) + + readonly table = injectTable(() => ({ + data: largeTableData, + columns: this.columns, + features: stockFeatures, + getRowId: (row) => row.id, + })) +} + +describe('flexRender large mixed table', () => { + const fixture = TestBed.createComponent(LargeMixedTable) + fixture.detectChanges() + + const instance = fixture.componentInstance + const renderedCellCount = fixture.nativeElement.querySelectorAll('td').length + if (renderedCellCount !== rowCount * columnCount) { + throw new Error(`Expected 1,200 cells, rendered ${renderedCellCount}`) + } + + bench( + 'unrelated host change with 1,200 mounted cells', + () => { + instance.hostTick.update((value) => value + 1) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'update 400 primitives and 400 stable component inputs', + () => { + instance.valueVersion.update((value) => value + 1) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'replace 200 component A/B cell views', + () => { + instance.componentKind.update((value) => (value === 'a' ? 'b' : 'a')) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'switch 200 cells between primitive and component views', + () => { + instance.contentKind.update((value) => + value === 'primitive' ? 'component' : 'primitive', + ) + fixture.detectChanges() + }, + benchmarkOptions, + ) +}) diff --git a/packages/angular-table/tests/flex-render/flex-render.unit.test.ts b/packages/angular-table/tests/flex-render/flex-render.unit.test.ts index 34c1c07797..e32d5eddbe 100644 --- a/packages/angular-table/tests/flex-render/flex-render.unit.test.ts +++ b/packages/angular-table/tests/flex-render/flex-render.unit.test.ts @@ -1,19 +1,15 @@ -import { - Component, - input, - signal, - ViewChild, - type TemplateRef, -} from '@angular/core' -import { TestBed, type ComponentFixture } from '@angular/core/testing' -import { describe, expect, test } from 'vitest' +import { Component, ViewChild, input, output, signal } from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { describe, expect, test, vi } from 'vitest' import { FlexRender, - flexRenderComponent, FlexRenderDirective, + flexRenderComponent, injectFlexRenderContext, } from '../../src' import { setFixtureSignalInput, setFixtureSignalInputs } from '../test-utils' +import type { ComponentFixture } from '@angular/core/testing' +import type { TemplateRef } from '@angular/core' describe('FlexRenderDirective', () => { test('should render primitives', () => { @@ -62,6 +58,34 @@ describe('FlexRenderDirective', () => { expect((fixture.nativeElement as HTMLElement).matches(':empty')).toBe(true) }) + test('should evaluate and update primitive content only when its dependencies change', () => { + const value = signal('Initial value') + const render = vi.fn(() => value()) + const fixture = TestBed.createComponent(TestRenderComponent) + + setFixtureSignalInputs(fixture, { + content: render, + context: {}, + }) + + const initialSpan = fixture.nativeElement.querySelector('span') + expect(render).toHaveBeenCalledTimes(1) + expect(initialSpan.textContent).toEqual('Initial value') + + fixture.detectChanges() + fixture.detectChanges() + + expect(render).toHaveBeenCalledTimes(1) + expect(fixture.nativeElement.querySelector('span')).toBe(initialSpan) + + value.set('Updated value') + fixture.detectChanges() + + expect(render).toHaveBeenCalledTimes(2) + expect(fixture.nativeElement.querySelector('span')).toBe(initialSpan) + expect(initialSpan.textContent).toEqual('Updated value') + }) + test('should render TemplateRef', () => { @Component({ template: ` @@ -122,6 +146,115 @@ describe('FlexRenderDirective', () => { expect(fixture.nativeElement.textContent).toEqual('Updated value') }) + test('should release and restore component output subscriptions', () => { + @Component({ + template: ``, + standalone: true, + }) + class FakeComponent { + readonly changed = output() + } + + const enabled = signal(true) + const listener = vi.fn() + const fixture = TestBed.createComponent(TestRenderComponent) + + setFixtureSignalInputs(fixture, { + content: () => + flexRenderComponent(FakeComponent, { + outputs: enabled() ? { changed: listener } : {}, + }), + context: {}, + }) + + const button = fixture.nativeElement.querySelector( + 'button', + ) as HTMLButtonElement + button.click() + expect(listener).toHaveBeenCalledTimes(1) + + enabled.set(false) + fixture.detectChanges() + button.click() + expect(listener).toHaveBeenCalledTimes(1) + + enabled.set(true) + fixture.detectChanges() + button.click() + expect(listener).toHaveBeenCalledTimes(2) + expect(fixture.nativeElement.querySelector('button')).toBe(button) + }) + + test('should set component inputs by property name when they have an alias', () => { + @Component({ + template: `{{ value() }}`, + standalone: true, + }) + class FakeComponent { + readonly value = input('', { alias: 'aliasedValue' }) + } + + const fixture = TestBed.createComponent(TestRenderComponent) + setFixtureSignalInputs(fixture, { + content: () => + flexRenderComponent(FakeComponent, { + inputs: { value: 'Aliased input value' }, + }), + context: {}, + }) + + expect(fixture.nativeElement.textContent).toEqual('Aliased input value') + }) + + test('should reuse a component by type and key and recreate it when the key changes', () => { + @Component({ + selector: 'app-keyed-component', + template: `{{ value() }}`, + standalone: true, + }) + class KeyedComponent { + readonly value = input.required() + } + + const key = signal('first') + const value = signal('Initial value') + const fixture = TestBed.createComponent(TestRenderComponent) + + setFixtureSignalInputs(fixture, { + content: () => + flexRenderComponent(KeyedComponent, { + key: key(), + inputs: { value: value() }, + // These creation-time arrays are intentionally recreated whenever + // the render function runs. They do not affect reuse without a new key. + bindings: [], + directives: [], + }), + context: {}, + }) + + const initialHost = fixture.nativeElement.querySelector( + 'app-keyed-component', + ) + expect(initialHost.textContent).toEqual('Initial value') + + value.set('Updated value') + fixture.detectChanges() + + expect(fixture.nativeElement.querySelector('app-keyed-component')).toBe( + initialHost, + ) + expect(initialHost.textContent).toEqual('Updated value') + + key.set(2) + fixture.detectChanges() + + expect(fixture.nativeElement.querySelector('app-keyed-component')).not.toBe( + initialHost, + ) + expect(fixture.nativeElement.textContent).toEqual('Updated value') + }) + test('should rerender when content has conditional return with different component types', () => { @Component({ selector: 'app-fake-a', diff --git a/packages/angular-table/tests/injectTable.test.ts b/packages/angular-table/tests/injectTable.test.ts index aee244543d..20b84a7af9 100644 --- a/packages/angular-table/tests/injectTable.test.ts +++ b/packages/angular-table/tests/injectTable.test.ts @@ -17,6 +17,21 @@ import { injectTable } from '../src' import type { PaginationState } from '../src' describe('injectTable', () => { + test('evaluates options once while constructing the table', () => { + const options = vi.fn(() => ({ + data: [], + features: stockFeatures, + columns: [], + })) + const table = TestBed.runInInjectionContext(() => injectTable(options)) + + expect(options).not.toHaveBeenCalled() + + void table.options + + expect(options).toHaveBeenCalledTimes(1) + }) + test('should support required signal inputs', async () => { type Data = { id: string; title: string }