diff --git a/docs/framework/angular/angular-table.md b/docs/framework/angular/angular-table.md
index c3e74071d2..d992a7ecea 100644
--- a/docs/framework/angular/angular-table.md
+++ b/docs/framework/angular/angular-table.md
@@ -40,41 +40,181 @@ FlexRender supports any type of content supported by Angular:
- A [TemplateRef](https://angular.dev/api/core/TemplateRef)
- A [Component](https://angular.dev/api/core/Component) wrapped into `FlexRenderComponent`
-Example:
+You can just use the `cell.renderValue` or `cell.getValue` APIs to render the cells of your table. However,
+these APIs will only spit out the raw cell values (from accessor functions).
+If you are using the `cell: () => any` column definition options, you will want to use the `FlexRenderDirective` from the adapter.
+
+Cell column definition is **reactive** and runs into an **injection context**, then you can inject services or make use of signals to automatically modify the rendered content.
+
+#### Example
```ts
@Component({
imports: [FlexRenderDirective],
//...
})
+class YourComponent {}
```
```angular-html
@for (row of table.getRowModel().rows; track row.id) {
-
- @for (cell of row.getVisibleCells(); track cell.id) {
-
-
+ @for (cell of row.getVisibleCells(); track cell.id) {
+
+
-
- {{ cell }}
-
-
-
-
-}
-
+ >
+
+ {{ cell }}
+
+
+
+
+ }
+
}
```
+#### Rendering a Component
+
+To render a Component into a specific column header/cell/footer, you can pass a `FlexRenderComponent` instantiated with
+your `ComponentType, with the ability to include parameters such as inputs, outputs and a custom injector.
+
+```ts
+import {flexRenderComponent} from "./flex-render-component";
+import {ChangeDetectionStrategy, input, output} from "@angular/core";
+
+@Component({
+ template: `
+ ...
+ `,
+ standalone: true,
+ changeDetectionStrategy: ChangeDetectionStrategy.OnPush,
+ host: {
+ '(click)': 'clickEvent.emit($event)'
+ }
+})
+class CustomCell {
+ readonly content = input.required();
+ readonly cellType = input();
+
+ // An output that will emit for every cell click
+ readonly clickEvent = output();
+}
+
+class AppComponent {
+ columns: ColumnDef[] = [
+ {
+ id: 'custom-cell',
+ header: () => {
+ const translateService = inject(TranslateService);
+ return translateService.translate('...');
+ },
+ cell: (context) => {
+ return flexRenderComponent(
+ MyCustomComponent,
+ {
+ injector, // Optional injector
+ inputs: {
+ // Mandatory input since we are using `input.required()
+ content: context.row.original.rowProperty,
+ // cellType? - Optional input
+ },
+ outputs: {
+ clickEvent: () => {
+ // Do something
+ }
+ }
+ }
+ )
+ },
+ },
+ ]
+}
+```
+
+Underneath, this utilizes
+the [ViewContainerRef#createComponent](https://angular.dev/api/core/ViewContainerRef#createComponent) api.
+Therefore, you should declare your custom inputs using the @Input decorator or input/model signals.
+
+You can still access the table cell context through the `injectFlexRenderContext` function, which returns the context
+value based on the props you pass to the `FlexRenderDirective`.
+
+```ts
+
+@Component({
+ // ...
+})
+class CustomCellComponent {
+ // context of a cell component
+ readonly context = injectFlexRenderContext>();
+ // context of a header/footer component
+ readonly context = injectFlexRenderContext>();
+}
+```
+
+Alternatively, you can render a component into a specific column header, cell, or footer by passing the component type
+to the corresponding column definitions. These column definitions will be provided to the `flexRender` directive along
+with the `context`.
+
+```ts
+class AppComponent {
+ columns: ColumnDef[] = [
+ {
+ id: 'select',
+ header: () => TableHeadSelectionComponent,
+ cell: () => TableRowSelectionComponent,
+ },
+ ]
+}
+```
+
+```angular-html
+
+ {{ headerCell }}
+
+```
+
+Properties of `context` provided in the `flexRender` directive will be accessible to your component.
+You can explicitly define the context properties required by your component.
+In this example, the context provided to flexRender is of type HeaderContext.
+Input signal `table`, which is a property of HeaderContext together with `column` and `header` properties,
+is then defined to be used in the component. If any of the context properties are
+needed in your component, feel free to use them. Please take note that only input signal is supported,
+when defining access to context properties, using this approach.
+
+```angular-ts
+@Component({
+ template: `
+
+ `,
+ // ...
+})
+export class TableHeadSelectionComponent {
+ //column = input.required>()
+ //header = input.required>()
+ table = input.required
>()
+}
+```
+
#### Rendering a TemplateRef
In order to render a TemplateRef into a specific column header/cell/footer, you can pass the TemplateRef into the column
@@ -171,101 +311,3 @@ class AppComponent {
]
}
```
-
-#### Rendering a Component
-
-To render a Component into a specific column header/cell/footer, you can pass a `FlexRenderComponent instantiated with
-your `ComponentType, with the ability to include optional parameters such as inputs and an injector.
-
-```ts
-import {FlexRenderComponent} from "@tanstack/angular-table";
-
-class AppComponent {
- columns: ColumnDef[] = [
- {
- id: 'customCell',
- header: () => new FlexRenderComponent(
- CustomCellComponent,
- {}, // optional inputs
- injector // optional injector
- ),
- cell: () => this.customCell(),
- },
- ]
-}
-```
-
-Underneath, this utilizes
-the [ViewContainerRef#createComponent](https://angular.dev/api/core/ViewContainerRef#createComponent) api.
-Therefore, you should declare your custom inputs using the @Input decorator or input/model signals.
-
-You can still access the table cell context through the `injectFlexRenderContext` function, which returns the context
-value based on the props you pass to the `FlexRenderDirective`.
-
-```ts
-@Component({
- // ...
-})
-class CustomCellComponent {
- // context of a cell component
- readonly context = injectFlexRenderContext>();
- // context of a header/footer component
- readonly context = injectFlexRenderContext>();
-}
-```
-
-Alternatively, you can render a component into a specific column header, cell, or footer by passing the component type
-to the corresponding column definitions. These column definitions will be provided to the `flexRender` directive along with the `context`.
-
-```ts
-import {FlexRenderComponent} from "@tanstack/angular-table";
-
-class AppComponent {
- columns: ColumnDef[] = [
- {
- id: 'select',
- header: () => TableHeadSelectionComponent,
- cell: () => TableRowSelectionComponent,
- },
- ]
-}
-```
-
-```angular2html
-
- {{ headerCell }}
-
-```
-
-Properties of `context` provided in the `flexRender` directive will be accessible to your component.
-You can explicitly define the context properties required by your component.
-In this example, the context provided to flexRender is of type HeaderContext.
-Input signal `table`, which is a property of HeaderContext together with `column` and `header` properties,
-is then defined to be used in the component. If any of the context properties are
-needed in your component, feel free to use them. Please take note that only input signal is supported,
-when defining access to context properties, using this approach.
-
-```angular-ts
-@Component({
- template: `
-
- `,
- // ...
-})
-export class TableHeadSelectionComponent {
- //column = input.required>()
- //header = input.required>()
- table = input.required
>()
-}
-```
\ No newline at end of file
diff --git a/examples/angular/row-selection/src/app/app.component.ts b/examples/angular/row-selection/src/app/app.component.ts
index 8711fd3959..da7ce36ef5 100644
--- a/examples/angular/row-selection/src/app/app.component.ts
+++ b/examples/angular/row-selection/src/app/app.component.ts
@@ -9,7 +9,7 @@ import {
import {
ColumnDef,
createAngularTable,
- FlexRenderComponent,
+ flexRenderComponent,
FlexRenderDirective,
getCoreRowModel,
getFilteredRowModel,
@@ -43,10 +43,10 @@ export class AppComponent {
{
id: 'select',
header: () => {
- return new FlexRenderComponent(TableHeadSelectionComponent)
+ return flexRenderComponent(TableHeadSelectionComponent)
},
cell: () => {
- return new FlexRenderComponent(TableRowSelectionComponent)
+ return flexRenderComponent(TableRowSelectionComponent)
},
},
{
diff --git a/packages/angular-table/package.json b/packages/angular-table/package.json
index 55aae671f5..e457315244 100644
--- a/packages/angular-table/package.json
+++ b/packages/angular-table/package.json
@@ -43,7 +43,7 @@
],
"scripts": {
"clean": "rimraf ./build",
- "test:types": "tsc --noEmit",
+ "test:types": "tsc --noEmit && vitest --typecheck",
"test:lib": "vitest",
"test:lib:dev": "vitest --watch",
"build": "ng-packagr -p ng-package.json -c tsconfig.build.json && rimraf ./build/lib/package.json"
@@ -53,11 +53,14 @@
"tslib": "^2.6.2"
},
"devDependencies": {
- "@analogjs/vite-plugin-angular": "^1.3.1",
+ "@analogjs/vite-plugin-angular": "^1.11.0",
+ "@analogjs/vitest-angular": "^1.11.0",
"@angular/core": "^17.3.9",
"@angular/platform-browser": "^17.3.9",
"@angular/platform-browser-dynamic": "^17.3.9",
- "ng-packagr": "^17.3.0"
+ "ng-packagr": "^17.3.0",
+ "typescript": "5.4.5",
+ "vitest": "^1.6.0"
},
"peerDependencies": {
"@angular/core": ">=17"
diff --git a/packages/angular-table/src/flex-render.ts b/packages/angular-table/src/flex-render.ts
index 86f97ae6cb..6a072f242e 100644
--- a/packages/angular-table/src/flex-render.ts
+++ b/packages/angular-table/src/flex-render.ts
@@ -1,20 +1,40 @@
import {
ChangeDetectorRef,
- ComponentRef,
Directive,
- EmbeddedViewRef,
+ DoCheck,
+ effect,
+ type EffectRef,
Inject,
inject,
- InjectionToken,
Injector,
Input,
- isSignal,
- type OnChanges,
- type SimpleChanges,
+ OnChanges,
+ runInInjectionContext,
+ SimpleChanges,
TemplateRef,
Type,
ViewContainerRef,
} from '@angular/core'
+import { FlexRenderComponentProps } from './flex-render/context'
+import { FlexRenderFlags } from './flex-render/flags'
+import {
+ flexRenderComponent,
+ FlexRenderComponent,
+} from './flex-render/flex-render-component'
+import { FlexRenderComponentFactory } from './flex-render/flex-render-component-ref'
+import {
+ FlexRenderComponentView,
+ FlexRenderTemplateView,
+ type FlexRenderTypedContent,
+ FlexRenderView,
+ mapToFlexRenderTypedContent,
+} from './flex-render/view'
+import { memo } from '@tanstack/table-core'
+
+export {
+ injectFlexRenderContext,
+ type FlexRenderComponentProps,
+} from './flex-render/context'
export type FlexRenderContent> =
| string
@@ -23,15 +43,20 @@ export type FlexRenderContent> =
| FlexRenderComponent
| TemplateRef<{ $implicit: TProps }>
| null
+ | Record
| undefined
@Directive({
selector: '[flexRender]',
standalone: true,
+ providers: [FlexRenderComponentFactory],
})
export class FlexRenderDirective>
- implements OnChanges
+ implements OnChanges, DoCheck
{
+ readonly #flexRenderComponentFactory = inject(FlexRenderComponentFactory)
+ readonly #changeDetectorRef = inject(ChangeDetectorRef)
+
@Input({ required: true, alias: 'flexRender' })
content:
| number
@@ -46,6 +71,24 @@ export class FlexRenderDirective>
@Input({ required: false, alias: 'flexRenderInjector' })
injector: Injector = inject(Injector)
+ renderFlags = FlexRenderFlags.ViewFirstRender
+ renderView: FlexRenderView | null = null
+
+ readonly #latestContent = () => {
+ const { content, props } = this
+ return typeof content !== 'function'
+ ? content
+ : runInInjectionContext(this.injector, () => content(props))
+ }
+
+ #getContentValue = memo(
+ () => [this.#latestContent(), this.props, this.content],
+ latestContent => {
+ return mapToFlexRenderTypedContent(latestContent)
+ },
+ { key: 'flexRenderContentValue', debug: () => false }
+ )
+
constructor(
@Inject(ViewContainerRef)
private readonly viewContainerRef: ViewContainerRef,
@@ -53,132 +96,191 @@ export class FlexRenderDirective>
private readonly templateRef: TemplateRef
) {}
- ref?: ComponentRef | EmbeddedViewRef | null = null
-
ngOnChanges(changes: SimpleChanges) {
- if (this.ref instanceof ComponentRef) {
- this.ref.injector.get(ChangeDetectorRef).markForCheck()
+ if (changes['props']) {
+ this.renderFlags |= FlexRenderFlags.PropsReferenceChanged
}
- if (!changes['content']) {
- return
+ if (changes['content']) {
+ this.renderFlags |=
+ FlexRenderFlags.ContentChanged | FlexRenderFlags.ViewFirstRender
+ this.update()
}
- this.render()
}
- render() {
- this.viewContainerRef.clear()
- const { content, props } = this
- if (content === null || content === undefined) {
- this.ref = null
+ ngDoCheck(): void {
+ if (this.renderFlags & FlexRenderFlags.ViewFirstRender) {
+ // On the initial render, the view is created during the `ngOnChanges` hook.
+ // Since `ngDoCheck` is called immediately afterward, there's no need to check for changes in this phase.
+ this.renderFlags &= ~FlexRenderFlags.ViewFirstRender
return
}
- if (typeof content === 'function') {
- return this.renderContent(content(props))
+
+ this.renderFlags |= FlexRenderFlags.DirtyCheck
+
+ const latestContent = this.#getContentValue()
+ if (latestContent.kind === 'null' || !this.renderView) {
+ this.renderFlags |= FlexRenderFlags.ContentChanged
} else {
- return this.renderContent(content)
+ this.renderView.content = latestContent
+ const { kind: previousKind } = this.renderView.previousContent
+ if (latestContent.kind !== previousKind) {
+ this.renderFlags |= FlexRenderFlags.ContentChanged
+ }
+ }
+ this.update()
+ }
+
+ update() {
+ if (
+ this.renderFlags &
+ (FlexRenderFlags.ContentChanged | FlexRenderFlags.ViewFirstRender)
+ ) {
+ this.render()
+ return
+ }
+ if (this.renderFlags & FlexRenderFlags.PropsReferenceChanged) {
+ if (this.renderView) this.renderView.updateProps(this.props)
+ this.renderFlags &= ~FlexRenderFlags.PropsReferenceChanged
+ }
+ if (
+ this.renderFlags &
+ (FlexRenderFlags.DirtyCheck | FlexRenderFlags.DirtySignal)
+ ) {
+ if (this.renderView) this.renderView.dirtyCheck()
+ this.renderFlags &= ~(
+ FlexRenderFlags.DirtyCheck | FlexRenderFlags.DirtySignal
+ )
}
}
- private renderContent(content: FlexRenderContent) {
- if (typeof content === 'string' || typeof content === 'number') {
- return this.renderStringContent()
+ #currentEffectRef: EffectRef | null = null
+
+ render() {
+ if (this.#shouldRecreateEntireView() && this.#currentEffectRef) {
+ this.#currentEffectRef.destroy()
+ this.#currentEffectRef = null
+ this.renderFlags &= ~FlexRenderFlags.RenderEffectChecked
+ }
+
+ this.viewContainerRef.clear()
+ this.renderFlags =
+ FlexRenderFlags.Pristine |
+ (this.renderFlags & FlexRenderFlags.ViewFirstRender) |
+ (this.renderFlags & FlexRenderFlags.RenderEffectChecked)
+
+ const resolvedContent = this.#getContentValue()
+ if (resolvedContent.kind === 'null') {
+ this.renderView = null
+ } else {
+ this.renderView = this.#renderViewByContent(resolvedContent)
}
- if (content instanceof TemplateRef) {
- return this.viewContainerRef.createEmbeddedView(
- content,
- this.getTemplateRefContext()
+
+ // If the content is a function `content(props)`, we initialize an effect
+ // in order to react to changes if the given definition use signals.
+ if (!this.#currentEffectRef && typeof this.content === 'function') {
+ this.#currentEffectRef = effect(
+ () => {
+ this.#latestContent()
+ if (!(this.renderFlags & FlexRenderFlags.RenderEffectChecked)) {
+ this.renderFlags |= FlexRenderFlags.RenderEffectChecked
+ return
+ }
+ this.renderFlags |= FlexRenderFlags.DirtySignal
+ // This will mark the view as changed,
+ // so we'll try to check for updates into ngDoCheck
+ this.#changeDetectorRef.markForCheck()
+ },
+ { injector: this.viewContainerRef.injector }
)
- } else if (content instanceof FlexRenderComponent) {
- return this.renderComponent(content)
- } else if (content instanceof Type) {
- return this.renderCustomComponent(content)
+ }
+ }
+
+ #shouldRecreateEntireView() {
+ return (
+ this.renderFlags &
+ FlexRenderFlags.ContentChanged &
+ FlexRenderFlags.ViewFirstRender
+ )
+ }
+
+ #renderViewByContent(
+ content: FlexRenderTypedContent
+ ): FlexRenderView | null {
+ if (content.kind === 'primitive') {
+ return this.#renderStringContent(content)
+ } else if (content.kind === 'templateRef') {
+ return this.#renderTemplateRefContent(content)
+ } else if (content.kind === 'flexRenderComponent') {
+ return this.#renderComponent(content)
+ } else if (content.kind === 'component') {
+ return this.#renderCustomComponent(content)
} else {
return null
}
}
- private renderStringContent(): EmbeddedViewRef {
+ #renderStringContent(
+ template: Extract
+ ): FlexRenderTemplateView {
const context = () => {
return typeof this.content === 'string' ||
typeof this.content === 'number'
? this.content
: this.content?.(this.props)
}
- return this.viewContainerRef.createEmbeddedView(this.templateRef, {
+ const ref = this.viewContainerRef.createEmbeddedView(this.templateRef, {
get $implicit() {
return context()
},
})
+ return new FlexRenderTemplateView(template, ref)
+ }
+
+ #renderTemplateRefContent(
+ template: Extract
+ ): FlexRenderTemplateView {
+ const latestContext = () => this.props
+ const view = this.viewContainerRef.createEmbeddedView(template.content, {
+ get $implicit() {
+ return latestContext()
+ },
+ })
+ return new FlexRenderTemplateView(template, view)
}
- private renderComponent(
- flexRenderComponent: FlexRenderComponent
- ): ComponentRef {
- const { component, inputs, injector } = flexRenderComponent
+ #renderComponent(
+ flexRenderComponent: Extract<
+ FlexRenderTypedContent,
+ { kind: 'flexRenderComponent' }
+ >
+ ): FlexRenderComponentView {
+ const { inputs, outputs, injector } = flexRenderComponent.content
const getContext = () => this.props
-
const proxy = new Proxy(this.props, {
- get: (_, key) => getContext()?.[key as keyof typeof _],
+ get: (_, key) => getContext()[key as keyof typeof _],
})
-
const componentInjector = Injector.create({
parent: injector ?? this.injector,
providers: [{ provide: FlexRenderComponentProps, useValue: proxy }],
})
-
- const componentRef = this.viewContainerRef.createComponent(component, {
- injector: componentInjector,
- })
- for (const prop in inputs) {
- if (componentRef.instance?.hasOwnProperty(prop)) {
- componentRef.setInput(prop, inputs[prop])
- }
- }
- return componentRef
- }
-
- private renderCustomComponent(
- component: Type
- ): ComponentRef {
- const componentRef = this.viewContainerRef.createComponent(component, {
- injector: this.injector,
- })
- for (const prop in this.props) {
- // Only signal based input can be added here
- if (
- componentRef.instance?.hasOwnProperty(prop) &&
- // @ts-ignore
- isSignal(componentRef.instance[prop])
- ) {
- componentRef.setInput(prop, this.props[prop])
- }
- }
- return componentRef
+ const view = this.#flexRenderComponentFactory.createComponent(
+ flexRenderComponent.content,
+ componentInjector
+ )
+ return new FlexRenderComponentView(flexRenderComponent, view)
}
- private getTemplateRefContext() {
- const getContext = () => this.props
- return {
- get $implicit() {
- return getContext()
- },
- }
+ #renderCustomComponent(
+ component: Extract
+ ): FlexRenderComponentView {
+ const view = this.#flexRenderComponentFactory.createComponent(
+ flexRenderComponent(component.content, {
+ inputs: this.props,
+ injector: this.injector,
+ }),
+ this.injector
+ )
+ return new FlexRenderComponentView(component, view)
}
}
-
-export class FlexRenderComponent> {
- constructor(
- readonly component: Type,
- readonly inputs: T = {} as T,
- readonly injector?: Injector
- ) {}
-}
-
-const FlexRenderComponentProps = new InjectionToken>(
- '[@tanstack/angular-table] Flex render component context props'
-)
-
-export function injectFlexRenderContext>(): T {
- return inject(FlexRenderComponentProps)
-}
diff --git a/packages/angular-table/src/flex-render/context.ts b/packages/angular-table/src/flex-render/context.ts
new file mode 100644
index 0000000000..b9eacac73c
--- /dev/null
+++ b/packages/angular-table/src/flex-render/context.ts
@@ -0,0 +1,9 @@
+import { inject, InjectionToken } from '@angular/core'
+
+export const FlexRenderComponentProps = new InjectionToken<
+ NonNullable
+>('[@tanstack/angular-table] Flex render component context props')
+
+export function injectFlexRenderContext>(): T {
+ return inject(FlexRenderComponentProps)
+}
diff --git a/packages/angular-table/src/flex-render/flags.ts b/packages/angular-table/src/flex-render/flags.ts
new file mode 100644
index 0000000000..7458f71143
--- /dev/null
+++ b/packages/angular-table/src/flex-render/flags.ts
@@ -0,0 +1,40 @@
+/**
+ * Flags used to manage and optimize the rendering lifecycle of content of the cell,
+ * while using FlexRenderDirective.
+ */
+export enum 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,
+ /**
+ * Represents a state where the view is not dirty, meaning no changes require rendering updates.
+ */
+ Pristine = 1 << 1,
+ /**
+ * 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 << 2,
+ /**
+ * 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 << 3,
+ /**
+ * Indicates that the current rendered view needs to be checked for changes.
+ */
+ DirtyCheck = 1 << 4,
+ /**
+ * Indicates that a signal within the `content(props)` result has changed
+ */
+ DirtySignal = 1 << 5,
+ /**
+ * Indicates that the first render effect has been checked at least one time.
+ */
+ RenderEffectChecked = 1 << 6,
+}
diff --git a/packages/angular-table/src/flex-render/flex-render-component-ref.ts b/packages/angular-table/src/flex-render/flex-render-component-ref.ts
new file mode 100644
index 0000000000..71ab7999a4
--- /dev/null
+++ b/packages/angular-table/src/flex-render/flex-render-component-ref.ts
@@ -0,0 +1,236 @@
+import {
+ ChangeDetectorRef,
+ ComponentRef,
+ inject,
+ Injectable,
+ Injector,
+ KeyValueDiffer,
+ KeyValueDiffers,
+ OutputEmitterRef,
+ OutputRefSubscription,
+ ViewContainerRef,
+} from '@angular/core'
+import { FlexRenderComponent } from './flex-render-component'
+
+@Injectable()
+export class FlexRenderComponentFactory {
+ #viewContainerRef = inject(ViewContainerRef)
+
+ createComponent(
+ flexRenderComponent: FlexRenderComponent,
+ componentInjector: Injector
+ ): FlexRenderComponentRef {
+ const componentRef = this.#viewContainerRef.createComponent(
+ flexRenderComponent.component,
+ {
+ injector: componentInjector,
+ }
+ )
+ const view = new FlexRenderComponentRef(
+ componentRef,
+ flexRenderComponent,
+ componentInjector
+ )
+
+ const { inputs, outputs } = flexRenderComponent
+
+ if (inputs) view.setInputs(inputs)
+ if (outputs) view.setOutputs(outputs)
+
+ return view
+ }
+}
+
+export class FlexRenderComponentRef {
+ readonly #keyValueDiffersFactory: KeyValueDiffers
+ #componentData: FlexRenderComponent
+ #inputValueDiffer: KeyValueDiffer
+
+ readonly #outputRegistry: FlexRenderComponentOutputManager
+
+ constructor(
+ readonly componentRef: ComponentRef,
+ componentData: FlexRenderComponent,
+ 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.componentRef.onDestroy(() => this.#outputRegistry.unsubscribeAll())
+ }
+
+ get component() {
+ return this.#componentData.component
+ }
+
+ get inputs() {
+ return this.#componentData.inputs ?? {}
+ }
+
+ get outputs() {
+ 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
+ */
+ eqType(compare: FlexRenderComponent): boolean {
+ return compare.component === this.component
+ }
+
+ /**
+ * 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)
+ })
+ }
+
+ this.#componentData = content
+ }
+
+ markAsDirty(): void {
+ this.componentRef.injector.get(ChangeDetectorRef).markForCheck()
+ }
+
+ setInputs(inputs: Record) {
+ for (const prop in inputs) {
+ this.setInput(prop, inputs[prop])
+ }
+ }
+
+ setInput(key: string, value: unknown) {
+ if (this.#componentData.allowedInputNames.includes(key)) {
+ this.componentRef.setInput(key, value)
+ }
+ }
+
+ setOutputs(
+ outputs: Record<
+ string,
+ OutputEmitterRef['emit'] | null | undefined
+ >
+ ) {
+ this.#outputRegistry.unsubscribeAll()
+ for (const prop in outputs) {
+ this.setOutput(prop, outputs[prop])
+ }
+ }
+
+ setOutput(
+ outputName: string,
+ emit: OutputEmitterRef['emit'] | undefined | null
+ ): void {
+ if (!this.#componentData.allowedOutputNames.includes(outputName)) return
+ if (!emit) {
+ this.#outputRegistry.unsubscribe(outputName)
+ return
+ }
+
+ const hasListener = this.#outputRegistry.hasListener(outputName)
+ this.#outputRegistry.setListener(outputName, emit)
+
+ if (hasListener) {
+ 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)
+ })
+ }
+ }
+}
+
+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
+ }
+
+ setListener(outputName: string, callback: (...args: any[]) => void) {
+ this.#outputListeners[outputName] = callback
+ }
+
+ getListener(outputName: string) {
+ return this.#outputListeners[outputName]
+ }
+
+ unsubscribeAll(): void {
+ for (const prop in this.#outputSubscribers) {
+ this.unsubscribe(prop)
+ }
+ }
+
+ unsubscribe(outputName: string) {
+ if (outputName in this.#outputSubscribers) {
+ this.#outputSubscribers[outputName]?.unsubscribe()
+ delete this.#outputSubscribers[outputName]
+ delete this.#outputListeners[outputName]
+ }
+ }
+
+ diff(outputs: Record['emit'] | undefined>) {
+ return this.#valueDiffer.diff(outputs ?? {})
+ }
+}
diff --git a/packages/angular-table/src/flex-render/flex-render-component.ts b/packages/angular-table/src/flex-render/flex-render-component.ts
new file mode 100644
index 0000000000..edf539cd35
--- /dev/null
+++ b/packages/angular-table/src/flex-render/flex-render-component.ts
@@ -0,0 +1,119 @@
+import {
+ ComponentMirror,
+ Injector,
+ InputSignal,
+ OutputEmitterRef,
+ reflectComponentType,
+ Type,
+} from '@angular/core'
+
+type Inputs = {
+ [K in keyof T as T[K] extends InputSignal
+ ? K
+ : never]?: T[K] extends InputSignal ? R : never
+}
+
+type Outputs = {
+ [K in keyof T as T[K] extends OutputEmitterRef
+ ? K
+ : never]?: T[K] extends OutputEmitterRef
+ ? OutputEmitterRef['emit']
+ : never
+}
+
+type OptionalKeys = K extends keyof T
+ ? T[K] extends Required[K]
+ ? undefined extends T[K]
+ ? K
+ : never
+ : K
+ : never
+
+interface FlexRenderRequiredOptions<
+ TInputs extends Record,
+ TOutputs extends Record,
+> {
+ /**
+ * Component instance inputs. They will be set via [componentRef.setInput API](https://angular.dev/api/core/ComponentRef#setInput)
+ */
+ inputs: TInputs
+ /**
+ * Component instance outputs.
+ */
+ outputs?: TOutputs
+ /**
+ * Optional {@link Injector} that will be used when rendering the component
+ */
+ injector?: Injector
+}
+
+interface FlexRenderOptions<
+ TInputs extends Record,
+ TOutputs extends Record,
+> {
+ /**
+ * Component instance inputs. They will be set via [componentRef.setInput API](https://angular.dev/api/core/ComponentRef#setInput)
+ */
+ inputs?: TInputs
+ /**
+ * Component instance outputs.
+ */
+ outputs?: TOutputs
+ /**
+ * Optional {@link Injector} that will be used when rendering the component
+ */
+ injector?: Injector
+}
+
+/**
+ * Helper function to create a [@link FlexRenderComponent] instance, with better type-safety.
+ *
+ * - options object must be passed when the given component instance contains at least one required signal input.
+ * - options/inputs is typed with the given component inputs
+ * - options/outputs is typed with the given component outputs
+ */
+export function flexRenderComponent<
+ TComponent = any,
+ TInputs extends Inputs = Inputs,
+ TOutputs extends Outputs = Outputs,
+>(
+ component: Type,
+ ...options: OptionalKeys extends never
+ ? [FlexRenderOptions?]
+ : [FlexRenderRequiredOptions]
+) {
+ const { inputs, injector, outputs } = options?.[0] ?? {}
+ return new FlexRenderComponent(component, inputs, injector, outputs)
+}
+
+/**
+ * Wrapper class for a component that will be used as content for {@link FlexRenderDirective}
+ *
+ * Prefer {@link flexRenderComponent} helper for better type-safety
+ */
+export class FlexRenderComponent {
+ readonly mirror: ComponentMirror
+ readonly allowedInputNames: string[] = []
+ readonly allowedOutputNames: string[] = []
+
+ constructor(
+ readonly component: Type,
+ readonly inputs?: Inputs,
+ readonly injector?: Injector,
+ readonly outputs?: Outputs
+ ) {
+ 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)
+ }
+ }
+}
diff --git a/packages/angular-table/src/flex-render/view.ts b/packages/angular-table/src/flex-render/view.ts
new file mode 100644
index 0000000000..9044b45abc
--- /dev/null
+++ b/packages/angular-table/src/flex-render/view.ts
@@ -0,0 +1,153 @@
+import { FlexRenderComponentRef } from './flex-render-component-ref'
+import { EmbeddedViewRef, TemplateRef, Type } from '@angular/core'
+import type { FlexRenderContent } from '../flex-render'
+import { FlexRenderComponent } from './flex-render-component'
+
+export type FlexRenderTypedContent =
+ | { kind: 'null' }
+ | {
+ kind: 'primitive'
+ content: string | number | Record
+ }
+ | { kind: 'flexRenderComponent'; content: FlexRenderComponent }
+ | { kind: 'templateRef'; content: TemplateRef }
+ | { kind: 'component'; content: Type }
+
+export function mapToFlexRenderTypedContent(
+ content: FlexRenderContent
+): FlexRenderTypedContent {
+ if (content === null || content === undefined) {
+ return { kind: 'null' }
+ }
+ if (typeof content === 'string' || typeof content === 'number') {
+ return { kind: 'primitive', content }
+ }
+ if (content instanceof FlexRenderComponent) {
+ return { kind: 'flexRenderComponent', content }
+ } else if (content instanceof TemplateRef) {
+ return { kind: 'templateRef', content }
+ } else if (content instanceof Type) {
+ return { kind: 'component', content }
+ } else {
+ return { kind: 'primitive', content }
+ }
+}
+
+export abstract class FlexRenderView<
+ TView extends FlexRenderComponentRef | EmbeddedViewRef | null,
+> {
+ readonly view: TView
+ #previousContent: FlexRenderTypedContent | undefined
+ #content: FlexRenderTypedContent
+
+ protected constructor(
+ initialContent: Exclude,
+ view: TView
+ ) {
+ this.#content = initialContent
+ 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
+ }
+
+ abstract updateProps(props: Record): void
+
+ abstract dirtyCheck(): void
+
+ abstract onDestroy(callback: Function): void
+}
+
+export class FlexRenderTemplateView extends FlexRenderView<
+ EmbeddedViewRef
+> {
+ constructor(
+ initialContent: Extract<
+ FlexRenderTypedContent,
+ { kind: 'primitive' | 'templateRef' }
+ >,
+ view: EmbeddedViewRef
+ ) {
+ super(initialContent, view)
+ }
+
+ override updateProps(props: Record) {
+ 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()
+ }
+
+ override onDestroy(callback: Function) {
+ this.view.onDestroy(callback)
+ }
+}
+
+export class FlexRenderComponentView extends FlexRenderView<
+ FlexRenderComponentRef
+> {
+ constructor(
+ initialContent: Extract<
+ FlexRenderTypedContent,
+ { kind: 'component' | 'flexRenderComponent' }
+ >,
+ view: FlexRenderComponentRef
+ ) {
+ super(initialContent, view)
+ }
+
+ override updateProps(props: Record) {
+ switch (this.content.kind) {
+ case 'component': {
+ this.view.setInputs(props)
+ break
+ }
+ case 'flexRenderComponent': {
+ // No-op. When FlexRenderFlags.PropsReferenceChanged is set,
+ // FlexRenderComponent will be updated into `dirtyCheck`.
+ break
+ }
+ }
+ }
+
+ override dirtyCheck() {
+ switch (this.content.kind) {
+ case 'component': {
+ // Component context is currently valuated with the cell context. Since it's reference
+ // shouldn't change, we force mark the component as dirty in order to re-evaluate function invocation in view.
+ // NOTE: this should behave like having a component with ChangeDetectionStrategy.Default
+ this.view.markAsDirty()
+ 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
+ if (this.view.eqType(this.content.content)) {
+ this.view.update(this.content.content)
+ }
+ this.view.markAsDirty()
+ break
+ }
+ }
+ }
+
+ override onDestroy(callback: Function) {
+ this.view.componentRef.onDestroy(callback)
+ }
+}
diff --git a/packages/angular-table/src/index.ts b/packages/angular-table/src/index.ts
index 086671e0aa..278239217c 100644
--- a/packages/angular-table/src/index.ts
+++ b/packages/angular-table/src/index.ts
@@ -14,11 +14,17 @@ export * from '@tanstack/table-core'
export {
type FlexRenderContent,
- FlexRenderComponent,
FlexRenderDirective,
+ FlexRenderDirective as FlexRender,
injectFlexRenderContext,
+ type FlexRenderComponentProps,
} from './flex-render'
+export {
+ FlexRenderComponent,
+ flexRenderComponent,
+} from './flex-render/flex-render-component'
+
export function createAngularTable(
options: () => TableOptions
): Table & Signal
> {
diff --git a/packages/angular-table/src/proxy.ts b/packages/angular-table/src/proxy.ts
index 660bb6bc40..80e0fa191a 100644
--- a/packages/angular-table/src/proxy.ts
+++ b/packages/angular-table/src/proxy.ts
@@ -23,8 +23,11 @@ export function proxifyTable(
*/
if (
property.startsWith('get') &&
- !property.endsWith('Handler') &&
- !property.endsWith('Model')
+ !property.endsWith('Handler')
+ // e.g. getCoreRowModel, getSelectedRowModel etc.
+ // We need that after a signal change even `rowModel` may mark the view as dirty.
+ // This allows to always get the latest `getContext` value while using flexRender
+ // && !property.endsWith('Model')
) {
const maybeFn = table[property] as Function | never
if (typeof maybeFn === 'function') {
diff --git a/packages/angular-table/tests/createAngularTable.test.ts b/packages/angular-table/tests/createAngularTable.test.ts
index 344cfe2630..9101270afa 100644
--- a/packages/angular-table/tests/createAngularTable.test.ts
+++ b/packages/angular-table/tests/createAngularTable.test.ts
@@ -1,13 +1,24 @@
-import { describe, expect, test } from 'vitest'
+import { describe, expect, test, vi } from 'vitest'
import {
type ColumnDef,
createAngularTable,
getCoreRowModel,
+ type RowSelectionState,
type Table,
+ RowModel,
+ type PaginationState,
+ getPaginationRowModel,
} from '../src/index'
-import { Component, input, isSignal, signal, untracked } from '@angular/core'
+import {
+ Component,
+ effect,
+ input,
+ isSignal,
+ signal,
+ untracked,
+} from '@angular/core'
import { TestBed } from '@angular/core/testing'
-import { setSignalInputs } from './test-utils'
+import { setFixtureSignalInputs } from './test-utils'
describe('createAngularTable', () => {
test('should render with required signal inputs', () => {
@@ -27,7 +38,7 @@ describe('createAngularTable', () => {
}
const fixture = TestBed.createComponent(FakeComponent)
- setSignalInputs(fixture.componentInstance, {
+ setFixtureSignalInputs(fixture, {
data: [],
})
@@ -73,6 +84,53 @@ describe('createAngularTable', () => {
const tableProperty = table[name as keyof typeof table]
expect(isSignal(tableProperty)).toEqual(expected)
})
+
+ test('Row model is reactive', () => {
+ const coreRowModelFn = vi.fn<[RowModel]>()
+ const rowModelFn = vi.fn<[RowModel]>()
+ const pagination = signal({
+ pageSize: 5,
+ pageIndex: 0,
+ })
+ const data = Array.from({ length: 10 }, (_, i) => ({
+ id: String(i),
+ title: `Title ${i}`,
+ }))
+
+ TestBed.runInInjectionContext(() => {
+ const table = createAngularTable(() => ({
+ data,
+ columns: columns,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getRowId: row => row.id,
+ state: {
+ pagination: pagination(),
+ },
+ onPaginationChange: updater => {
+ typeof updater === 'function'
+ ? pagination.update(updater)
+ : pagination.set(updater)
+ },
+ }))
+
+ effect(() => coreRowModelFn(table.getCoreRowModel()))
+ effect(() => rowModelFn(table.getRowModel()))
+
+ TestBed.flushEffects()
+
+ pagination.set({ pageIndex: 0, pageSize: 3 })
+
+ TestBed.flushEffects()
+ })
+
+ expect(coreRowModelFn).toHaveBeenCalledOnce()
+ expect(coreRowModelFn.mock.calls[0]![0].rows.length).toEqual(10)
+
+ expect(rowModelFn).toHaveBeenCalledTimes(2)
+ expect(rowModelFn.mock.calls[0]![0].rows.length).toEqual(5)
+ expect(rowModelFn.mock.calls[1]![0].rows.length).toEqual(3)
+ })
})
})
@@ -80,7 +138,8 @@ const testShouldBeComputedProperty = (
table: Table,
propertyName: string
) => {
- if (propertyName.endsWith('Handler') || propertyName.endsWith('Model')) {
+ if (propertyName.endsWith('Handler')) {
+ // || propertyName.endsWith('Model')) {
return false
}
diff --git a/packages/angular-table/tests/flex-render-component.test-d.ts b/packages/angular-table/tests/flex-render-component.test-d.ts
new file mode 100644
index 0000000000..dca2c7b989
--- /dev/null
+++ b/packages/angular-table/tests/flex-render-component.test-d.ts
@@ -0,0 +1,27 @@
+import { input } from '@angular/core'
+import { test } from 'vitest'
+import { flexRenderComponent } from '../src'
+
+test('Infer component inputs', () => {
+ class Test {
+ readonly input1 = input()
+ }
+
+ // @ts-expect-error Must pass right type as a value
+ flexRenderComponent(Test, { inputs: { input1: 1 } })
+
+ // Input is optional so we can skip passing the property
+ flexRenderComponent(Test, { inputs: {} })
+})
+
+test('Options are mandatory when given component has required inputs', () => {
+ class Test {
+ readonly input1 = input()
+ readonly requiredInput1 = input.required()
+ }
+
+ // @ts-expect-error Required input
+ flexRenderComponent(Test)
+
+ flexRenderComponent(Test, { inputs: { requiredInput1: 'My value' } })
+})
diff --git a/packages/angular-table/tests/flex-render-table.test.ts b/packages/angular-table/tests/flex-render-table.test.ts
new file mode 100644
index 0000000000..6894a86b9f
--- /dev/null
+++ b/packages/angular-table/tests/flex-render-table.test.ts
@@ -0,0 +1,449 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ input,
+ output,
+ signal,
+ type TemplateRef,
+ ViewChild,
+} from '@angular/core'
+import {
+ type CellContext,
+ ColumnDef,
+ type ExpandedState,
+ getCoreRowModel,
+ type TableOptions,
+ type TableState,
+} from '@tanstack/table-core'
+import {
+ createAngularTable,
+ FlexRender,
+ flexRenderComponent,
+ type FlexRenderContent,
+ injectFlexRenderContext,
+} from '../src'
+import { TestBed } from '@angular/core/testing'
+import { describe, expect, test, vi } from 'vitest'
+import { By } from '@angular/platform-browser'
+
+const defaultData: TestData[] = [{ id: '1', title: 'My title' }] as TestData[]
+
+const defaultColumns: ColumnDef[] = [
+ {
+ id: 'title',
+ accessorKey: 'title',
+ header: 'Title',
+ cell: props => props.renderValue(),
+ },
+]
+
+describe('FlexRenderDirective', () => {
+ test.each([null, undefined])('Render %s as empty', value => {
+ const { fixture, dom } = createTestTable(defaultData, [
+ { id: 'first_cell', header: 'header', cell: () => value },
+ ])
+ const row = dom.getBodyRow(0)!
+ const firstCell = row.querySelector('td')
+
+ expect(firstCell!.matches(':empty')).toBe(true)
+ })
+
+ test.each([
+ ['String column via function', () => 'My string'],
+ ['String column', () => 'My string'],
+ ['Number column via function', () => 0],
+ ['Number column', 0],
+ ])('Render primitive (%s)', (columnName, columnValue) => {
+ const { fixture, dom } = createTestTable(defaultData, [
+ { id: 'first_cell', header: columnName, cell: columnValue as any },
+ ])
+ const row = dom.getBodyRow(0)!
+ const firstCell = row.querySelector('td')
+
+ expectPrimitiveValueIs(
+ firstCell,
+ String(typeof columnValue === 'function' ? columnValue() : columnValue)
+ )
+ })
+
+ test('Render TemplateRef', () => {
+ @Component({
+ template: `
+ Cell id: {{ context.cell.id }}
+ `,
+ standalone: true,
+ })
+ class FakeTemplateRefComponent {
+ @ViewChild('template', { static: true })
+ templateRef!: TemplateRef
+ }
+
+ const templateRef = TestBed.createComponent(FakeTemplateRefComponent)
+ .componentInstance.templateRef
+
+ const { dom } = createTestTable(defaultData, [
+ { id: 'first_cell', header: 'Header', cell: () => templateRef },
+ ])
+
+ const row = dom.getBodyRow(0)!
+ const firstCell = row.querySelector('td')
+ expect(firstCell!.textContent).toEqual('Cell id: 0_first_cell')
+ })
+
+ test('Render component with FlexRenderComponent', async () => {
+ const status = signal('Initial status')
+
+ const { dom } = createTestTable(defaultData, [
+ {
+ id: 'first_cell',
+ header: 'Status',
+ cell: () => {
+ return flexRenderComponent(TestBadgeComponent, {
+ inputs: {
+ status: status(),
+ },
+ })
+ },
+ },
+ ])
+
+ let row = dom.getBodyRow(0)!
+ let firstCell = row.querySelector('td')
+ expect(firstCell!.textContent).toEqual('Initial status')
+
+ status.set('Updated status')
+ dom.clickTriggerCdButton()
+
+ expect(firstCell!.textContent).toEqual('Updated status')
+ })
+
+ test('Render content reactively based on signal value', async () => {
+ const statusComponent = signal>('Initial status')
+
+ const { dom, fixture } = createTestTable(defaultData, [
+ {
+ id: 'first_cell',
+ header: 'Status',
+ cell: () => {
+ return statusComponent()
+ },
+ },
+ ])
+
+ let row = dom.getBodyRow(0)!
+ let firstCell = row.querySelector('td')
+
+ expect(firstCell!.textContent).toEqual('Initial status')
+
+ statusComponent.set(null)
+ fixture.detectChanges()
+ expect(firstCell!.matches(':empty')).toBe(true)
+
+ statusComponent.set(
+ flexRenderComponent(TestBadgeComponent, {
+ inputs: { status: 'Updated status' },
+ })
+ )
+ fixture.detectChanges()
+ const el = firstCell!.firstElementChild as HTMLElement
+ expect(el!.tagName).toEqual('APP-TEST-BADGE')
+ expect(el.textContent).toEqual('Updated status')
+ })
+
+ test('Cell content always get the latest context value', async () => {
+ const contextCaptor = vi.fn()
+
+ const tableState = signal>({
+ rowSelection: {},
+ })
+
+ @Component({
+ template: ``,
+ })
+ class EmptyCell {}
+
+ const { dom, fixture } = createTestTable(
+ defaultData,
+ [
+ {
+ id: 'cell',
+ header: 'Header',
+ cell: context => {
+ contextCaptor(context)
+ return flexRenderComponent(EmptyCell)
+ },
+ },
+ ],
+ () => ({
+ state: tableState(),
+ onStateChange: updater => {
+ return typeof updater === 'function'
+ ? tableState.update(updater as any)
+ : tableState.set(updater)
+ },
+ })
+ )
+
+ const latestCall = () =>
+ contextCaptor.mock.lastCall[0] as CellContext
+ // TODO: As a perf improvement, check in a future if we can avoid evaluating the cell twice during the first render.
+ // This is caused due to the registration of the initial effect and the first #getContentValue() to detect the
+ // type of content to render.
+ expect(contextCaptor).toHaveBeenCalledTimes(2)
+
+ expect(latestCall().row.getIsExpanded()).toEqual(false)
+ expect(latestCall().row.getIsSelected()).toEqual(false)
+
+ fixture.componentInstance.table.getRow('0').toggleSelected(true)
+ dom.clickTriggerCdButton2()
+ expect(contextCaptor).toHaveBeenCalledTimes(3)
+ expect(latestCall().row.getIsSelected()).toEqual(true)
+
+ fixture.componentInstance.table.getRow('0').toggleSelected(false)
+ fixture.componentInstance.table.getRow('0').toggleExpanded(true)
+ dom.clickTriggerCdButton2()
+ expect(contextCaptor).toHaveBeenCalledTimes(4)
+ expect(latestCall().row.getIsSelected()).toEqual(false)
+ expect(latestCall().row.getIsExpanded()).toEqual(true)
+ })
+
+ test('Support cell with component output', async () => {
+ const columns = [
+ {
+ id: 'expand',
+ header: 'Expand',
+ cell: ({ row }) => {
+ return flexRenderComponent(ExpandCell, {
+ inputs: { expanded: row.getIsExpanded() },
+ outputs: { toggleExpand: () => row.toggleExpanded() },
+ })
+ },
+ },
+ ] satisfies ColumnDef[]
+
+ @Component({
+ selector: 'expand-cell',
+ template: `
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ standalone: true,
+ })
+ class ExpandCell {
+ readonly expanded = input(false)
+ readonly toggleExpand = output()
+ }
+
+ @Component({
+ template: `
+
+
+ @for (row of table.getRowModel().rows; track row.id) {
+
+ @for (cell of row.getVisibleCells(); track cell.id) {
+