diff --git a/.changeset/fancy-times-jog.md b/.changeset/fancy-times-jog.md new file mode 100644 index 0000000000..add52e7044 --- /dev/null +++ b/.changeset/fancy-times-jog.md @@ -0,0 +1,5 @@ +--- +'@tanstack/angular-table': patch +--- + +improve flexRender instance reuse and reduce adapter allocations diff --git a/packages/angular-table/package.json b/packages/angular-table/package.json index 24f2f30fe7..9c592f369f 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 index e265c847c8..6a26987f8b 100644 --- a/packages/angular-table/src/flex-render/flags.ts +++ b/packages/angular-table/src/flex-render/flags.ts @@ -1,34 +1,33 @@ /** - * Flags used to manage and optimize the rendering lifecycle of the content of the cell - * while using {@link FlexViewRenderer}. + * Flags used to manage and optimize the rendering lifecycle of content inside + * {@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. + * The renderer has not completed its initial update. The first update creates + * the view from scratch, then clears this flag. */ 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. + * The `content` input changed by reference, or its resolved value is not + * compatible with the mounted view. The next update recreates the view. */ 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 + * The `props` input changed by reference. Components receive the latest + * inputs and embedded templates are marked so their getter-backed context is + * evaluated again. */ 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 + * A render function produced compatible content that must be synchronized + * with the mounted view without recreating it. */ Dirty: 1 << 3, /** - * Indicates that the first render effect has been checked at least one time. + * The render-function effect completed its initial dependency read. That + * first execution records dependencies; subsequent executions update the + * view. */ 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..396b2a0017 100644 --- a/packages/angular-table/src/flex-render/flexRenderComponent.ts +++ b/packages/angular-table/src/flex-render/flexRenderComponent.ts @@ -17,11 +17,35 @@ 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 +78,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} @@ -88,6 +116,8 @@ interface FlexRenderOptions< * * These values are assigned after the component has been created using * [componentRef.setInput API](https://angular.dev/api/core/ComponentRef#setInput). + * On a reused component, omitted keys keep their current value. Pass + * `undefined` explicitly when an input needs to be cleared. * * Shouldn't be used together with {@link FlexRenderOptions#bindings} option */ @@ -101,7 +131,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 +185,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 +193,7 @@ export function flexRenderComponent( outputs, directives, bindings, + key, ) } @@ -208,17 +243,20 @@ export interface FlexRenderComponent { */ readonly component: Type /** - * Reflected metadata about the component. + * Optional identity used together with the component type to decide whether + * an existing component instance can be reused. + * + * @see {@link FlexRenderOptions#key} */ - readonly mirror: ComponentMirror + readonly key?: string | number /** - * List of allowed input names. + * Reflected metadata about the component. */ - readonly allowedInputNames: Array + readonly mirror: ComponentMirror /** - * List of allowed output names. + * Cached component metadata used by the flex renderer. */ - readonly allowedOutputNames: Array + readonly metadata: ResolvedComponentMetadata /** * Component instance outputs. Subscribed via {@link OutputEmitterRef#subscribe} * @@ -254,14 +292,13 @@ export interface FlexRenderComponent { /** * Wrapper class for a component that will be used as content for {@link FlexRenderDirective} * - * Prefer {@link flexRenderComponent} helper for better type-safety + * Prefer {@link flexRenderComponent} for better type-safety. */ export class FlexRenderComponentInstance< TComponent = any, > implements FlexRenderComponent { readonly mirror: ComponentMirror - readonly allowedInputNames: Array = [] - readonly allowedOutputNames: Array = [] + readonly metadata: ResolvedComponentMetadata constructor( readonly component: Type, @@ -270,19 +307,46 @@ 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) + this.metadata = resolveComponentTypeMetadata(component) + this.mirror = this.metadata.mirror + } +} + +interface ResolvedComponentMetadata { + readonly mirror: ComponentMirror + readonly inputNames: ReadonlyMap + readonly outputNames: ReadonlySet +} + +const typeCache = new WeakMap, ResolvedComponentMetadata>() + +function resolveComponentTypeMetadata( + type: Type, +): ResolvedComponentMetadata { + let metadata = typeCache.get(type) as ResolvedComponentMetadata | undefined + if (metadata) return metadata + const mirror = reflectComponentType(type) + if (!mirror) { + throw new Error( + `[@tanstack-table/angular] The provided symbol is not a component`, + ) + } + const inputNames = new Map() + const outputNames = new Set() + for (const input of mirror.inputs) { + inputNames.set(input.propName, input.templateName) + if (input.templateName !== input.propName) { + inputNames.set(input.templateName, input.templateName) } } + for (const output of mirror.outputs) { + // Outputs are read from the component instance, so only their class + // property names are valid here. Template aliases are not instance keys. + outputNames.add(output.propName) + } + metadata = { mirror, inputNames, outputNames } + typeCache.set(type, metadata) + return metadata } diff --git a/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts b/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts index 0cd39c79ea..2e1148f310 100644 --- a/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts +++ b/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts @@ -3,13 +3,11 @@ import { ComponentRef, Injectable, Injector, - KeyValueDiffer, - KeyValueDiffers, OutputEmitterRef, OutputRefSubscription, ViewContainerRef, } from '@angular/core' -import { FlexRenderComponent } from './flexRenderComponent' +import type { FlexRenderComponent } from './flexRenderComponent' /** * Creates and manages Angular component instances used by flex-rendered table @@ -32,7 +30,7 @@ export class FlexRenderComponentFactory { { injector: componentInjector, directives: flexRenderComponent.directives, - bindings: flexRenderComponent.bindings ?? [], + bindings: flexRenderComponent.bindings, }, ) const view = new FlexRenderComponentRef( @@ -57,10 +55,8 @@ export class FlexRenderComponentFactory { * be reused instead of recreated on every cell/header render. */ export class FlexRenderComponentRef { - readonly #keyValueDiffersFactory: KeyValueDiffers #componentData: FlexRenderComponent - #inputValueDiffer: KeyValueDiffer - + readonly #creationKey: FlexRenderComponent['key'] readonly #outputRegistry: FlexRenderComponentOutputManager constructor( @@ -69,18 +65,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 +82,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 +90,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 } @@ -153,15 +111,15 @@ export class FlexRenderComponentRef { } setInputs(inputs: Record) { - for (const prop in inputs) { + for (const prop of Object.keys(inputs)) { this.setInput(prop, inputs[prop]) } } setInput(key: string, value: unknown) { - if (this.#componentData.allowedInputNames.includes(key)) { - this.componentRef.setInput(key, value) - } + const inputName = this.#componentData.metadata.inputNames.get(key) + if (inputName === undefined) return + this.componentRef.setInput(inputName, value) } setOutputs( @@ -171,81 +129,106 @@ export class FlexRenderComponentRef { >, ) { this.#outputRegistry.unsubscribeAll() - for (const prop in outputs) { + for (const prop of Object.keys(outputs)) { this.setOutput(prop, outputs[prop]) } } setOutput( - outputName: string, + key: string, emit: OutputEmitterRef['emit'] | undefined | null, ): void { - if (!this.#componentData.allowedOutputNames.includes(outputName)) return + if (!this.#componentData.metadata.outputNames.has(key)) return + const outputName = key if (!emit) { this.#outputRegistry.unsubscribe(outputName) return } - const hasListener = this.#outputRegistry.hasListener(outputName) + // If the output was already subscribed, just swap the listener callback. + 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(newInputs: Record): void { + // Inputs use patch semantics: omitted keys keep their current value, while + // an explicitly provided `undefined` is forwarded to Angular. + for (const prop of Object.keys(newInputs)) { + this.setInput(prop, newInputs[prop]) + } + } + + #syncOutputs( + outputs: Record< + string, + OutputEmitterRef['emit'] | null | undefined + >, + ): void { + const outputKeys = Object.keys(outputs) + const currentSubscribedKeys = this.#outputRegistry.getSubscribedKeys() + // When outputs updates, unsubscribe missing keys + for (const key of currentSubscribedKeys) { + if (!outputKeys.includes(key)) { + this.#outputRegistry.unsubscribe(key) + } + } + for (const prop of outputKeys) { + this.setOutput(prop, outputs[prop]) } } } 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) - } + readonly #outputSubscribers = new Map() + readonly #outputListeners = new Map) => void>() + + getSubscribedKeys() { + return Array.from(this.#outputListeners.keys()) } - hasListener(outputName: string) { - return outputName in this.#outputListeners + hasSubscription(outputName: string) { + return this.#outputSubscribers.has(outputName) } setListener(outputName: string, callback: (...args: Array) => void) { - this.#outputListeners[outputName] = callback + this.#outputListeners.set(outputName, callback) } getListener(outputName: string) { - return this.#outputListeners[outputName] + return this.#outputListeners.get(outputName) } - unsubscribeAll(): void { - for (const prop in this.#outputSubscribers) { - this.unsubscribe(prop) - } + setSubscription( + outputName: string, + subscription: OutputRefSubscription, + ): void { + this.#outputSubscribers.set(outputName, subscription) } - unsubscribe(outputName: string) { - if (outputName in this.#outputSubscribers) { - this.#outputSubscribers[outputName]?.unsubscribe() - delete this.#outputSubscribers[outputName] - delete this.#outputListeners[outputName] + unsubscribeAll(): void { + for (const outputName of this.#outputListeners.keys()) { + this.unsubscribe(outputName) } } - diff(outputs: Record['emit'] | undefined>) { - return this.#valueDiffer.diff(outputs) + unsubscribe(outputName: string) { + this.#outputSubscribers.get(outputName)?.unsubscribe() + this.#outputSubscribers.delete(outputName) + this.#outputListeners.delete(outputName) } } diff --git a/packages/angular-table/src/flex-render/renderer.ts b/packages/angular-table/src/flex-render/renderer.ts index 20064719ff..205bb0a196 100644 --- a/packages/angular-table/src/flex-render/renderer.ts +++ b/packages/angular-table/src/flex-render/renderer.ts @@ -114,6 +114,7 @@ export class FlexViewRenderer< FlexRenderViewAllowedType, FlexRenderTypedContent > | null = null + #outerRenderEffectRef: EffectRef | null = null #currentRenderEffectRef: EffectRef | null = null #content: () => FlexRenderInputContent #props: () => TProps @@ -132,9 +133,8 @@ export class FlexViewRenderer< readonly #latestContent = computed(() => this.#getLatestContentValue()) - #getContentValue = computed(() => { - const latestContent = this.#latestContent() - return mapToFlexRenderTypedContent(latestContent) + readonly #getContentValue = computed(() => { + return mapToFlexRenderTypedContent(this.#latestContent()) }) constructor(options: RendererViewOptions) { @@ -149,45 +149,66 @@ export class FlexViewRenderer< } mount(): EffectRef { - let previousContent: FlexRenderInputContent - let previousProps: TProps - - return effect(() => { - const props = this.#props() - const content = this.#content() + if (this.#outerRenderEffectRef) { + return this.#outerRenderEffectRef + } - if (!(this.#renderFlags & FlexRenderFlags.ViewFirstRender)) { - if (previousContent !== content) { - this.#renderFlags |= FlexRenderFlags.ContentChanged - } - if (previousProps !== props) { - this.#renderFlags |= FlexRenderFlags.PropsReferenceChanged + let previousContent: FlexRenderInputContent | undefined + let previousProps: TProps | undefined + + this.#outerRenderEffectRef = effect( + () => { + const props = this.#props() + const content = this.#content() + + if (!(this.#renderFlags & FlexRenderFlags.ViewFirstRender)) { + if (previousContent !== content) { + // A new content input may install a different render function (or + // stop rendering a function), so its dependency effect must be + // replaced. Incompatible values returned by the same function only + // recreate the view and keep the existing effect. + this.#destroyContentEffect() + this.#renderFlags |= FlexRenderFlags.ContentChanged + } + if (previousProps !== props) { + this.#renderFlags |= FlexRenderFlags.PropsReferenceChanged + } } - } - untracked(() => this.#update()) + untracked(() => this.#update()) - if (FlexRenderFlags.ViewFirstRender & this.#renderFlags) { - this.#renderFlags &= ~FlexRenderFlags.ViewFirstRender - } + if (this.#renderFlags & FlexRenderFlags.ViewFirstRender) { + this.#renderFlags &= ~FlexRenderFlags.ViewFirstRender + } - previousContent = content - previousProps = props - }) + previousContent = content + previousProps = props + }, + { injector: this.#viewContainerRef.injector }, + ) + + return this.#outerRenderEffectRef } destroy(): void { + if (this.#outerRenderEffectRef) { + this.#outerRenderEffectRef.destroy() + this.#outerRenderEffectRef = null + } + this.#destroyContentEffect() + this.#destroyView() + this.#renderFlags = FlexRenderFlags.ViewFirstRender + } + + #destroyContentEffect(): void { if (this.#currentRenderEffectRef) { this.#currentRenderEffectRef.destroy() this.#currentRenderEffectRef = null } - if (this.#renderView) { - this.#renderView.unmount() - this.#renderView = null - } + this.#renderFlags &= ~FlexRenderFlags.RenderEffectChecked } - #update() { + #update(): void { if ( this.#renderFlags & (FlexRenderFlags.ContentChanged | FlexRenderFlags.ViewFirstRender) @@ -197,85 +218,73 @@ export class FlexViewRenderer< } if (this.#renderFlags & FlexRenderFlags.PropsReferenceChanged) { - if (this.#renderView) this.#renderView.updateProps(this.#props()) + this.#renderView?.updateProps(this.#props()) this.#renderFlags &= ~FlexRenderFlags.PropsReferenceChanged } if (this.#renderFlags & FlexRenderFlags.Dirty) { - if (this.#renderView) this.#renderView.dirtyCheck() + this.#renderView?.dirtyCheck() this.#renderFlags &= ~FlexRenderFlags.Dirty } } - #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(): void { + // Resolved content can require a new view without changing the render + // function. Preserve its effect and checked state across that replacement. + this.#destroyView() - this.#viewContainerRef.clear() - if (this.#renderView) { - this.#renderView.unmount() - this.#renderView = null - } + this.#renderFlags &= + FlexRenderFlags.ViewFirstRender | FlexRenderFlags.RenderEffectChecked - this.#renderFlags = - (this.#renderFlags & FlexRenderFlags.ViewFirstRender) | - (this.#renderFlags & FlexRenderFlags.RenderEffectChecked) + const content = this.#getContentValue() + this.#renderView = this.#renderViewByContent(content) - 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 + // Render functions can read signals. Keep their dependency tracking in a + // dedicated effect so the outer effect remains responsible only for + // content and props input-reference changes. if ( !this.#currentRenderEffectRef && typeof untracked(this.#content) === 'function' ) { this.#currentRenderEffectRef = effect( () => { - this.#latestContent() + const latestContent = this.#getContentValue() if (!(this.#renderFlags & FlexRenderFlags.RenderEffectChecked)) { this.#renderFlags |= FlexRenderFlags.RenderEffectChecked return } - this.#renderFlags |= FlexRenderFlags.Dirty - this.#doCheck() + + untracked(() => { + this.#renderFlags |= FlexRenderFlags.Dirty + this.#doCheck(latestContent) + }) }, { injector: this.#viewContainerRef.injector }, ) } } - #shouldRecreateEntireView() { - return ( - this.#renderFlags & - FlexRenderFlags.ContentChanged & - FlexRenderFlags.ViewFirstRender - ) - } - - #doCheck() { - const latestContent = this.#getContentValue() - if (latestContent.kind === 'null' || !this.#renderView) { + #doCheck(latestContent: FlexRenderTypedContent): void { + if ( + latestContent.kind === 'null' || + !this.#renderView || + !this.#renderView.canReuse(latestContent) + ) { 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() } + #destroyView(): void { + if (this.#renderView) { + this.#renderView.unmount() + this.#renderView = null + } + } + #renderViewByContent( content: FlexRenderTypedContent, ): FlexRenderView | null { @@ -287,25 +296,20 @@ export class FlexViewRenderer< return this.#renderComponent(content) } else if (content.kind === 'component') { return this.#renderCustomComponent(content) - } else { - return null } + return null } #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 latestContent = () => untracked(this.#getContentValue) const ref = this.#viewContainerRef.createEmbeddedView(this.#templateRef, { get $implicit() { - return context() + // The view can be checked while an incompatible replacement is being + // scheduled. Only expose content that still belongs to this context. + const content = latestContent() + return content.kind === 'primitive' ? content.content : undefined }, }) return new FlexRenderTemplateView(template, ref) @@ -314,12 +318,12 @@ export class FlexViewRenderer< #renderTemplateRefContent( template: Extract, ): FlexRenderTemplateView { - const latestContext = () => this.#props() + const latestProps = () => untracked(this.#props) const view = this.#viewContainerRef.createEmbeddedView( template.content, { get $implicit() { - return latestContext() + return latestProps() }, }, { injector: this.#getInjector() }, @@ -333,8 +337,9 @@ export class FlexViewRenderer< { kind: 'flexRenderComponent' } >, ): FlexRenderComponentView { - const { injector } = flexRenderComponent.content - const componentInjector = this.#getInjector(injector) + const componentInjector = this.#getInjector( + flexRenderComponent.content.injector, + ) const view = this.#flexRenderComponentFactory.createComponent( flexRenderComponent.content, componentInjector, diff --git a/packages/angular-table/src/flex-render/view.ts b/packages/angular-table/src/flex-render/view.ts index 39e93c2675..902d0899ca 100644 --- a/packages/angular-table/src/flex-render/view.ts +++ b/packages/angular-table/src/flex-render/view.ts @@ -50,7 +50,6 @@ export abstract class FlexRenderView< TContent extends FlexRenderTypedContent, > { readonly view: TView - #previousContent: FlexRenderTypedContent | undefined #content: FlexRenderTypedContent protected constructor( @@ -61,16 +60,11 @@ export abstract class FlexRenderView< this.view = view } - get previousContent(): FlexRenderTypedContent { - return this.#previousContent ?? { kind: 'null' } - } - get content() { return this.#content } set content(content: FlexRenderTypedContent) { - this.#previousContent = this.#content this.#content = content } @@ -78,9 +72,7 @@ export abstract class FlexRenderView< abstract dirtyCheck(): void - abstract onDestroy(callback: Function): void - - abstract eq(view: TContent): boolean + abstract canReuse(content: TContent): boolean abstract unmount(): void } @@ -106,36 +98,35 @@ export class FlexRenderTemplateView extends FlexRenderView< } override updateProps(_props: Record) { - this.view.markForCheck() + if (this.content.kind === 'templateRef') { + // Template contexts are getter-backed. Mark the embedded view so Angular + // reads the latest props; the context object itself does not need to be + // replaced. + 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') { + // Primitive contexts are getter-backed too. The renderer has already + // memoized the new value, so checking the view is enough to refresh + // `$implicit` without mutating the context. + this.view.markForCheck() + } } override unmount() { this.view.destroy() } - override onDestroy(callback: Function) { - this.view.onDestroy(callback) - } - - override eq( + override canReuse( compare: Extract< FlexRenderTypedContent, { kind: 'primitive' | 'templateRef' } >, ): 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) @@ -170,8 +161,8 @@ export class FlexRenderComponentView extends FlexRenderView< break } case 'flexRenderComponent': { - // No-op. When FlexRenderFlags.PropsReferenceChanged is set, - // FlexRenderComponent will be updated into `dirtyCheck`. + // No-op. A props change can produce a new wrapper descriptor; its + // inputs and outputs are synchronized by `dirtyCheck`. break } } @@ -187,8 +178,9 @@ 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 + // Render functions commonly create a new descriptor on every run. If + // its type and key still identify the mounted instance, update that + // instance instead of recreating the component view. if (this.view.eqType(this.content.content)) { this.view.update(this.content.content) } @@ -202,11 +194,7 @@ export class FlexRenderComponentView extends FlexRenderView< this.view.componentRef.destroy() } - override onDestroy(callback: Function) { - this.view.componentRef.onDestroy(callback) - } - - override eq( + override canReuse( compare: Extract< FlexRenderTypedContent, { kind: 'component' | 'flexRenderComponent' } @@ -218,7 +206,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/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..e92ca5638c 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,123 @@ 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 memoize resolved content across input and internal signal updates', () => { + const value = signal('first') + const render = vi.fn( + (context: Record) => `${context['label']}:${value()}`, + ) + const fixture = TestBed.createComponent(TestRenderComponent) + + setFixtureSignalInputs(fixture, { + content: render, + context: { label: 'initial' }, + }) + + expect(render).toHaveBeenCalledTimes(1) + expect(fixture.nativeElement.textContent).toEqual('initial:first') + + setFixtureSignalInput(fixture, 'context', { label: 'updated' }) + fixture.detectChanges() + + expect(render).toHaveBeenCalledTimes(2) + expect(fixture.nativeElement.textContent).toEqual('updated:first') + + value.set('second') + fixture.detectChanges() + + expect(render).toHaveBeenCalledTimes(3) + expect(fixture.nativeElement.textContent).toEqual('updated:second') + }) + + test('should replace render-function effects when the content input changes', () => { + const firstValue = signal('first') + const secondValue = signal('second') + const firstRender = vi.fn(() => firstValue()) + const secondRender = vi.fn(() => secondValue()) + const fixture = TestBed.createComponent(TestRenderComponent) + + setFixtureSignalInputs(fixture, { + content: firstRender, + context: {}, + }) + + expect(firstRender).toHaveBeenCalledTimes(1) + expect(fixture.nativeElement.textContent).toEqual('first') + + setFixtureSignalInput(fixture, 'content', secondRender) + fixture.detectChanges() + + expect(secondRender).toHaveBeenCalledTimes(1) + expect(fixture.nativeElement.textContent).toEqual('second') + + firstValue.set('stale first') + fixture.detectChanges() + + expect(firstRender).toHaveBeenCalledTimes(1) + expect(fixture.nativeElement.textContent).toEqual('second') + + setFixtureSignalInput(fixture, 'content', 'static') + fixture.detectChanges() + secondValue.set('stale second') + fixture.detectChanges() + + expect(secondRender).toHaveBeenCalledTimes(1) + expect(fixture.nativeElement.textContent).toEqual('static') + + setFixtureSignalInput(fixture, 'content', firstRender) + fixture.detectChanges() + firstValue.set('live first') + fixture.detectChanges() + + expect(firstRender).toHaveBeenCalledTimes(3) + expect(fixture.nativeElement.textContent).toEqual('live first') + }) + + test('should react when a render function changes from null to content', () => { + const visible = signal(false) + const fixture = TestBed.createComponent(TestRenderComponent) + + setFixtureSignalInputs(fixture, { + content: () => (visible() ? 'Visible' : null), + context: {}, + }) + + expect((fixture.nativeElement as HTMLElement).matches(':empty')).toBe(true) + + visible.set(true) + fixture.detectChanges() + + expectPrimitiveValueIs(fixture, 'Visible') + }) + test('should render TemplateRef', () => { @Component({ template: ` @@ -122,6 +235,229 @@ 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 subscribe to aliased outputs by property name', () => { + @Component({ + template: ``, + standalone: true, + }) + class FakeComponent { + readonly changed = output({ alias: 'aliasedChanged' }) + } + + const listener = vi.fn() + const fixture = TestBed.createComponent(TestRenderComponent) + setFixtureSignalInputs(fixture, { + content: () => + flexRenderComponent(FakeComponent, { + outputs: { changed: listener }, + }), + context: {}, + }) + + fixture.nativeElement.querySelector('button').click() + + expect(listener).toHaveBeenCalledTimes(1) + }) + + test('should preserve omitted inputs and forward explicit undefined', () => { + @Component({ + selector: 'app-patched-input-component', + template: `{{ value() === undefined ? 'undefined' : value() }}`, + standalone: true, + }) + class FakeComponent { + readonly value = input('initial') + } + + const mode = signal<'set' | 'omit' | 'clear'>('set') + const fixture = TestBed.createComponent(TestRenderComponent) + setFixtureSignalInputs(fixture, { + content: () => { + const currentMode = mode() + const inputs: { value?: string | undefined } = + currentMode === 'set' + ? { value: 'updated' } + : currentMode === 'clear' + ? { value: undefined } + : {} + return flexRenderComponent(FakeComponent, { inputs }) + }, + context: {}, + }) + + const initialHost = fixture.nativeElement.querySelector( + 'app-patched-input-component', + ) + expect(initialHost.textContent).toEqual('updated') + + mode.set('omit') + fixture.detectChanges() + + expect( + fixture.nativeElement.querySelector('app-patched-input-component'), + ).toBe(initialHost) + expect(initialHost.textContent).toEqual('updated') + + mode.set('clear') + fixture.detectChanges() + + expect(initialHost.textContent).toEqual('undefined') + }) + + 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 recreate content when the content function reference changes', () => { + @Component({ + selector: 'app-reusable-component', + template: `{{ value() }}`, + standalone: true, + }) + class ReusableComponent { + readonly value = input.required() + } + + const firstRender = vi.fn(() => + flexRenderComponent(ReusableComponent, { + key: 'stable', + inputs: { value: 'first' }, + }), + ) + const secondRender = vi.fn(() => + flexRenderComponent(ReusableComponent, { + key: 'stable', + inputs: { value: 'second' }, + }), + ) + const fixture = TestBed.createComponent(TestRenderComponent) + + setFixtureSignalInputs(fixture, { + content: firstRender, + context: {}, + }) + + const initialHost = fixture.nativeElement.querySelector( + 'app-reusable-component', + ) + expect(firstRender).toHaveBeenCalledTimes(1) + expect(initialHost.textContent).toEqual('first') + + setFixtureSignalInput(fixture, 'content', secondRender) + fixture.detectChanges() + + expect(secondRender).toHaveBeenCalledTimes(1) + expect( + fixture.nativeElement.querySelector('app-reusable-component'), + ).not.toBe(initialHost) + expect(fixture.nativeElement.textContent).toEqual('second') + }) + test('should rerender when content has conditional return with different component types', () => { @Component({ selector: 'app-fake-a', @@ -160,8 +496,6 @@ describe('FlexRenderDirective', () => { expect(fixture.nativeElement.textContent).toEqual('B component') }) - // Skip for now, test framework (using ComponentRef.setInput) cannot recognize signal inputs - // as component inputs test('should render custom components', async () => { @Component({ template: `{{ row().property }}`, @@ -193,6 +527,36 @@ describe('FlexRenderDirective', () => { expect(fixture.nativeElement.textContent).toEqual('Updated value') }) + + test('should ignore context properties that are not component inputs', () => { + @Component({ + template: `{{ row() }}`, + standalone: true, + }) + class FakeComponent { + readonly row = input.required() + } + + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + try { + const fixture = TestBed.createComponent(TestRenderComponent) + setFixtureSignalInputs(fixture, { + content: () => FakeComponent, + context: { + row: 'Known input', + unknownContextProperty: 'Ignored value', + }, + }) + + expect(fixture.nativeElement.textContent).toEqual('Known input') + expect(consoleError).not.toHaveBeenCalled() + } finally { + consoleError.mockRestore() + } + }) }) @Component({ diff --git a/packages/angular-table/tests/injectTable.test.ts b/packages/angular-table/tests/injectTable.test.ts index e8804b5b5b..f3e78df489 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 }