From cf9d10498303f331aa041e6b61518c46336de9f0 Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Fri, 7 Aug 2026 01:25:53 +0200 Subject: [PATCH 01/23] perf: revisit angular adapter to improve memory usage and flexrender dirty checking --- packages/angular-table/package.json | 1 + .../angular-table/src/flex-render/flags.ts | 34 --- .../src/flex-render/flexRenderComponent.ts | 90 +++++-- .../flex-render/flexRenderComponentFactory.ts | 200 ++++++++------- .../angular-table/src/flex-render/renderer.ts | 236 ++++++----------- .../angular-table/src/flex-render/view.ts | 47 ++-- .../src/helpers/flexRenderCell.ts | 7 +- packages/angular-table/src/injectTable.ts | 5 +- packages/angular-table/src/reactivity.ts | 8 +- .../flex-render-component.test-d.ts | 6 + .../tests/flex-render/flex-render.bench.ts | 239 ++++++++++++++++++ .../flex-render/flex-render.unit.test.ts | 153 ++++++++++- .../angular-table/tests/injectTable.test.ts | 15 ++ 13 files changed, 703 insertions(+), 338 deletions(-) delete mode 100644 packages/angular-table/src/flex-render/flags.ts create mode 100644 packages/angular-table/tests/flex-render/flex-render.bench.ts diff --git a/packages/angular-table/package.json b/packages/angular-table/package.json index f416893bdd..e1d39df50d 100644 --- a/packages/angular-table/package.json +++ b/packages/angular-table/package.json @@ -56,6 +56,7 @@ "scripts": { "build": "ng-packagr -p ng-package.json -c tsconfig.build.json && rimraf ./dist/package.json && find dist -name '*.map' -delete", "build:types": "tsc --emitDeclarationOnly", + "bench:flex-render": "vitest bench --run tests/flex-render/flex-render.bench.ts", "clean": "rimraf ./build && rimraf ./dist", "test:build": "publint --strict", "test:eslint": "eslint ./src", diff --git a/packages/angular-table/src/flex-render/flags.ts b/packages/angular-table/src/flex-render/flags.ts deleted file mode 100644 index e265c847c8..0000000000 --- a/packages/angular-table/src/flex-render/flags.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Flags used to manage and optimize the rendering lifecycle of the content of the cell - * while using {@link FlexViewRenderer}. - */ -export const FlexRenderFlags = { - /** - * Indicates that the view is being created for the first time or will be cleared during the next update phase. - * This is the initial state and will transition after the first ngDoCheck. - */ - ViewFirstRender: 1 << 0, - /** - * Indicates the `content` property has been modified or the view requires a complete re-render. - * When this flag is enabled, the view will be cleared and recreated from scratch. - */ - ContentChanged: 1 << 1, - /** - * Indicates that the `props` property reference has changed. - * When this flag is enabled, the view context is updated based on the type of the content. - * - * For Component view, inputs will be updated and view will be marked as dirty. - * For TemplateRef and primitive values, view will be marked as dirty - */ - PropsReferenceChanged: 1 << 2, - /** - * Indicates that the current rendered view needs to be checked for changes. - * This will be set to true when `content(props)` result has changed or during - * forced update - */ - Dirty: 1 << 3, - /** - * Indicates that the first render effect has been checked at least one time. - */ - RenderEffectChecked: 1 << 4, -} as const diff --git a/packages/angular-table/src/flex-render/flexRenderComponent.ts b/packages/angular-table/src/flex-render/flexRenderComponent.ts index 5dc958c352..113c4596e3 100644 --- a/packages/angular-table/src/flex-render/flexRenderComponent.ts +++ b/packages/angular-table/src/flex-render/flexRenderComponent.ts @@ -13,15 +13,50 @@ type CreateComponentOptions = Parameters[1] type CreateComponentBindings = CreateComponentOptions['bindings'] type CreateComponentDirectives = CreateComponentOptions['directives'] +interface FlexRenderComponentMetadata { + mirror: ComponentMirror + allowedInputNames: Array + allowedOutputNames: Array +} + +const componentMetadataCache = new WeakMap< + Type, + FlexRenderComponentMetadata +>() + interface FlexRenderOptions< TInputs extends Record, TOutputs extends Record, > { + /** + * Optional identity used to control component instance reuse. + * + * A rendered component is reused while both its component type and key are + * unchanged. Change the key to explicitly destroy and recreate the component, + * for example when new creation-time bindings, directives, or an injector + * need to be applied. + * + * Inputs and outputs do not affect component identity and are synchronized + * onto a reused component instance. + * + * @example + * ```ts + * flexRenderComponent(EditorComponent, { + * key: row.original.editorVersion, + * inputs: { value: row.original.value }, + * }) + * ``` + */ + readonly key?: string | number /** * Native Angular bindings applied at component creation time via `createComponent`. * Use this option to set inputs, outputs, or two-way bindings at creation time. * Shouldn't be used together with {@link FlexRenderOptions#inputs} or {@link FlexRenderOptions#outputs} option. * + * Bindings are creation-time configuration. Changing this array after the + * component has mounted does not update the existing component. Change + * {@link FlexRenderOptions#key} to recreate the component with new bindings. + * * Binding input/outputs at creation time: {@link https://angular.dev/guide/components/programmatic-rendering#binding-inputs-outputs-and-setting-host-directives-at-creation} * * Two-way binding: {@link https://angular.dev/api/core/twoWayBinding} @@ -54,6 +89,10 @@ interface FlexRenderOptions< /** * Directives to apply to the component at creation time. * + * Directives are creation-time configuration. Changing this array after the + * component has mounted does not update the existing component. Change + * {@link FlexRenderOptions#key} to recreate the component with new directives. + * * Binding directives at creation time: {@link https://angular.dev/guide/components/programmatic-rendering#binding-inputs-outputs-and-setting-host-directives-at-creation} * * Two-way binding: {@link https://angular.dev/api/core/twoWayBinding} @@ -101,7 +140,11 @@ interface FlexRenderOptions< */ readonly outputs?: TOutputs /** - * Optional {@link Injector} that will be used when rendering the component + * Optional {@link Injector} that will be used when rendering the component. + * + * The injector is applied when the component is created. Change + * {@link FlexRenderOptions#key} to recreate a mounted component with a + * different injector. */ readonly injector?: Injector } @@ -151,7 +194,7 @@ export function flexRenderComponent( component: Type, options?: FlexRenderOptions, Outputs>, ): FlexRenderComponent { - const { inputs, injector, outputs, directives, bindings } = options ?? {} + const { key, inputs, injector, outputs, directives, bindings } = options ?? {} return new FlexRenderComponentInstance( component, inputs, @@ -159,6 +202,7 @@ export function flexRenderComponent( outputs, directives, bindings, + key, ) } @@ -207,6 +251,13 @@ export interface FlexRenderComponent { * The component type */ readonly component: Type + /** + * Optional identity used together with the component type to decide whether + * an existing component instance can be reused. + * + * @see {@link FlexRenderOptions#key} + */ + readonly key?: string | number /** * Reflected metadata about the component. */ @@ -260,8 +311,8 @@ export class FlexRenderComponentInstance< TComponent = any, > implements FlexRenderComponent { readonly mirror: ComponentMirror - readonly allowedInputNames: Array = [] - readonly allowedOutputNames: Array = [] + readonly allowedInputNames: Array + readonly allowedOutputNames: Array constructor( readonly component: Type, @@ -270,19 +321,26 @@ export class FlexRenderComponentInstance< readonly outputs?: Outputs, readonly directives?: CreateComponentDirectives, readonly bindings?: CreateComponentBindings, + readonly key?: string | number, ) { - const mirror = reflectComponentType(component) - if (!mirror) { - throw new Error( - `[@tanstack-table/angular] The provided symbol is not a component`, - ) - } - this.mirror = mirror - for (const input of this.mirror.inputs) { - this.allowedInputNames.push(input.propName) - } - for (const output of this.mirror.outputs) { - this.allowedOutputNames.push(output.propName) + let metadata = componentMetadataCache.get(component) as + FlexRenderComponentMetadata | undefined + if (!metadata) { + const mirror = reflectComponentType(component) + if (!mirror) { + throw new Error( + `[@tanstack-table/angular] The provided symbol is not a component`, + ) + } + metadata = { + mirror, + allowedInputNames: mirror.inputs.map((input) => input.propName), + allowedOutputNames: mirror.outputs.map((output) => output.propName), + } + componentMetadataCache.set(component, metadata) } + this.mirror = metadata.mirror + this.allowedInputNames = metadata.allowedInputNames + this.allowedOutputNames = metadata.allowedOutputNames } } diff --git a/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts b/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts index 0cd39c79ea..ad93672e46 100644 --- a/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts +++ b/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts @@ -3,13 +3,33 @@ import { ComponentRef, Injectable, Injector, - KeyValueDiffer, - KeyValueDiffers, OutputEmitterRef, OutputRefSubscription, ViewContainerRef, } from '@angular/core' import { FlexRenderComponent } from './flexRenderComponent' +import type { Type } from '@angular/core' + +const inputNameCache = new WeakMap, Map>() +const hasOwn = (value: object, key: PropertyKey): boolean => + Object.prototype.hasOwnProperty.call(value, key) + +function getInputName( + componentData: FlexRenderComponent, + propName: string, +): string | undefined { + let names = inputNameCache.get(componentData.component) + if (!names) { + names = new Map( + componentData.mirror.inputs.map((input) => [ + input.propName, + input.templateName, + ]), + ) + inputNameCache.set(componentData.component, names) + } + return names.get(propName) +} /** * Creates and manages Angular component instances used by flex-rendered table @@ -32,7 +52,7 @@ export class FlexRenderComponentFactory { { injector: componentInjector, directives: flexRenderComponent.directives, - bindings: flexRenderComponent.bindings ?? [], + bindings: flexRenderComponent.bindings, }, ) const view = new FlexRenderComponentRef( @@ -57,10 +77,9 @@ export class FlexRenderComponentFactory { * be reused instead of recreated on every cell/header render. */ export class FlexRenderComponentRef { - readonly #keyValueDiffersFactory: KeyValueDiffers #componentData: FlexRenderComponent - #inputValueDiffer: KeyValueDiffer - + readonly #inputValues: Record = {} + readonly #creationKey: FlexRenderComponent['key'] readonly #outputRegistry: FlexRenderComponentOutputManager constructor( @@ -69,17 +88,8 @@ export class FlexRenderComponentRef { readonly componentInjector: Injector, ) { this.#componentData = componentData - this.#keyValueDiffersFactory = componentInjector.get(KeyValueDiffers) - - this.#outputRegistry = new FlexRenderComponentOutputManager( - this.#keyValueDiffersFactory, - this.outputs, - ) - - this.#inputValueDiffer = this.#keyValueDiffersFactory - .find(this.inputs) - .create() - this.#inputValueDiffer.diff(this.inputs) + this.#creationKey = componentData.key + this.#outputRegistry = new FlexRenderComponentOutputManager() this.componentRef.onDestroy(() => this.#outputRegistry.unsubscribeAll()) } @@ -96,15 +106,6 @@ export class FlexRenderComponentRef { return this.#componentData.outputs ?? {} } - /** - * Get component input and output diff by the given item - */ - diff(item: FlexRenderComponent) { - return { - inputDiff: this.#inputValueDiffer.diff(item.inputs ?? {}), - outputDiff: this.#outputRegistry.diff(item.outputs ?? {}), - } - } /** * * @param compare Whether the current ref component instance is the same as the given one @@ -113,37 +114,18 @@ export class FlexRenderComponentRef { return compare.component === this.component } + canReuse(compare: FlexRenderComponent): boolean { + return this.eqType(compare) && Object.is(compare.key, this.#creationKey) + } + /** * Tries to update current component refs input by the new given content component. */ - update(content: FlexRenderComponent) { - const eq = this.eqType(content) - if (!eq) return - const { inputDiff, outputDiff } = this.diff(content) - if (inputDiff) { - inputDiff.forEachAddedItem((item) => - this.setInput(item.key, item.currentValue), - ) - inputDiff.forEachChangedItem((item) => - this.setInput(item.key, item.currentValue), - ) - inputDiff.forEachRemovedItem((item) => this.setInput(item.key, undefined)) - } - if (outputDiff) { - outputDiff.forEachAddedItem((item) => { - this.setOutput(item.key, item.currentValue) - }) - outputDiff.forEachChangedItem((item) => { - if (item.currentValue) { - this.#outputRegistry.setListener(item.key, item.currentValue) - } else { - this.#outputRegistry.unsubscribe(item.key) - } - }) - outputDiff.forEachRemovedItem((item) => { - this.#outputRegistry.unsubscribe(item.key) - }) - } + update(content: FlexRenderComponent): void { + if (!this.canReuse(content)) return + + this.#syncInputs(content.inputs ?? {}) + this.#syncOutputs(content.outputs ?? {}) this.#componentData = content } @@ -154,13 +136,21 @@ export class FlexRenderComponentRef { setInputs(inputs: Record) { for (const prop in inputs) { - this.setInput(prop, inputs[prop]) + if (hasOwn(inputs, prop)) { + this.setInput(prop, inputs[prop]) + } } } + updateInputs(inputs: Record): void { + this.#syncInputs(inputs) + } + setInput(key: string, value: unknown) { - if (this.#componentData.allowedInputNames.includes(key)) { - this.componentRef.setInput(key, value) + const inputName = getInputName(this.#componentData, key) + if (inputName) { + this.componentRef.setInput(inputName, value) + this.#inputValues[key] = value } } @@ -172,7 +162,9 @@ export class FlexRenderComponentRef { ) { this.#outputRegistry.unsubscribeAll() for (const prop in outputs) { - this.setOutput(prop, outputs[prop]) + if (hasOwn(outputs, prop)) { + this.setOutput(prop, outputs[prop]) + } } } @@ -186,41 +178,70 @@ export class FlexRenderComponentRef { return } - const hasListener = this.#outputRegistry.hasListener(outputName) + const hasSubscription = this.#outputRegistry.hasSubscription(outputName) this.#outputRegistry.setListener(outputName, emit) - if (hasListener) { + if (hasSubscription) { return } const instance = this.componentRef.instance const output = instance[outputName as keyof typeof instance] if (output && output instanceof OutputEmitterRef) { - output.subscribe((value) => { - this.#outputRegistry.getListener(outputName)?.(value) - }) + this.#outputRegistry.setSubscription( + outputName, + output.subscribe((value) => { + this.#outputRegistry.getListener(outputName)?.(value) + }), + ) } } + + #syncInputs(inputs: Record): void { + for (const prop in inputs) { + if ( + hasOwn(inputs, prop) && + (!hasOwn(this.#inputValues, prop) || + !Object.is(this.#inputValues[prop], inputs[prop])) + ) { + this.setInput(prop, inputs[prop]) + } + } + for (const prop in this.#inputValues) { + if (!hasOwn(inputs, prop)) { + const inputName = getInputName(this.#componentData, prop) + if (inputName) { + this.componentRef.setInput(inputName, undefined) + } + delete this.#inputValues[prop] + } + } + } + + #syncOutputs( + outputs: Record< + string, + OutputEmitterRef['emit'] | null | undefined + >, + ): void { + for (const prop in outputs) { + if ( + hasOwn(outputs, prop) && + !Object.is(this.#outputRegistry.getListener(prop), outputs[prop]) + ) { + this.setOutput(prop, outputs[prop]) + } + } + this.#outputRegistry.unsubscribeMissing(outputs) + } } class FlexRenderComponentOutputManager { readonly #outputSubscribers: Record = {} readonly #outputListeners: Record) => void> = {} - readonly #valueDiffer: KeyValueDiffer< - string, - undefined | null | OutputEmitterRef['emit'] - > - - constructor(keyValueDiffers: KeyValueDiffers, initialOutputs: any) { - this.#valueDiffer = keyValueDiffers.find(initialOutputs).create() - if (initialOutputs) { - this.#valueDiffer.diff(initialOutputs) - } - } - - hasListener(outputName: string) { - return outputName in this.#outputListeners + hasSubscription(outputName: string) { + return outputName in this.#outputSubscribers } setListener(outputName: string, callback: (...args: Array) => void) { @@ -231,21 +252,30 @@ class FlexRenderComponentOutputManager { return this.#outputListeners[outputName] } + setSubscription( + outputName: string, + subscription: OutputRefSubscription, + ): void { + this.#outputSubscribers[outputName] = subscription + } + unsubscribeAll(): void { - for (const prop in this.#outputSubscribers) { + for (const prop in this.#outputListeners) { this.unsubscribe(prop) } } - unsubscribe(outputName: string) { - if (outputName in this.#outputSubscribers) { - this.#outputSubscribers[outputName]?.unsubscribe() - delete this.#outputSubscribers[outputName] - delete this.#outputListeners[outputName] + unsubscribeMissing(outputs: Record): void { + for (const prop in this.#outputListeners) { + if (!hasOwn(outputs, prop)) { + this.unsubscribe(prop) + } } } - diff(outputs: Record['emit'] | undefined>) { - return this.#valueDiffer.diff(outputs) + unsubscribe(outputName: string) { + this.#outputSubscribers[outputName]?.unsubscribe() + delete this.#outputSubscribers[outputName] + delete this.#outputListeners[outputName] } } diff --git a/packages/angular-table/src/flex-render/renderer.ts b/packages/angular-table/src/flex-render/renderer.ts index 20064719ff..bc56f1bb78 100644 --- a/packages/angular-table/src/flex-render/renderer.ts +++ b/packages/angular-table/src/flex-render/renderer.ts @@ -1,6 +1,5 @@ import { Injector, - computed, effect, runInInjectionContext, untracked, @@ -9,7 +8,6 @@ import { TanStackTableCellToken } from '../helpers/cell' import { TanStackTableHeaderToken } from '../helpers/header' import { TanStackTableToken } from '../helpers/table' import { FlexRenderComponentProps } from './context' -import { FlexRenderFlags } from './flags' import { flexRenderComponent } from './flexRenderComponent' import { FlexRenderComponentFactory } from './flexRenderComponentFactory' import { @@ -109,12 +107,12 @@ export class FlexViewRenderer< | CellContext | HeaderContext, > { - #renderFlags = FlexRenderFlags.ViewFirstRender #renderView: FlexRenderView< FlexRenderViewAllowedType, FlexRenderTypedContent > | null = null - #currentRenderEffectRef: EffectRef | null = null + #renderEffectRef: EffectRef | null = null + #previousProps: TProps | undefined #content: () => FlexRenderInputContent #props: () => TProps #injector: () => Injector @@ -122,21 +120,6 @@ export class FlexViewRenderer< #templateRef: TemplateRef #flexRenderComponentFactory: FlexRenderComponentFactory - readonly #getLatestContentValue = () => { - const content = this.#content() - const props = this.#props() - return typeof content !== 'function' - ? content - : runInInjectionContext(this.#injector(), () => content(props)) - } - - readonly #latestContent = computed(() => this.#getLatestContentValue()) - - #getContentValue = computed(() => { - const latestContent = this.#latestContent() - return mapToFlexRenderTypedContent(latestContent) - }) - constructor(options: RendererViewOptions) { this.#content = options.content this.#props = options.props @@ -149,180 +132,123 @@ export class FlexViewRenderer< } mount(): EffectRef { - let previousContent: FlexRenderInputContent - let previousProps: TProps - - return effect(() => { - const props = this.#props() - const content = this.#content() - - if (!(this.#renderFlags & FlexRenderFlags.ViewFirstRender)) { - if (previousContent !== content) { - this.#renderFlags |= FlexRenderFlags.ContentChanged - } - if (previousProps !== props) { - this.#renderFlags |= FlexRenderFlags.PropsReferenceChanged - } - } - - untracked(() => this.#update()) + if (this.#renderEffectRef) { + return this.#renderEffectRef + } - if (FlexRenderFlags.ViewFirstRender & this.#renderFlags) { - this.#renderFlags &= ~FlexRenderFlags.ViewFirstRender - } + this.#renderEffectRef = effect( + () => { + const content = this.#content() + const props = this.#props() + const injector = this.#injector() + const resolvedContent = + typeof content === 'function' + ? runInInjectionContext(injector, () => content(props)) + : content + + untracked(() => + this.#update( + mapToFlexRenderTypedContent(resolvedContent), + props, + injector, + ), + ) + }, + { injector: this.#viewContainerRef.injector }, + ) - previousContent = content - previousProps = props - }) + return this.#renderEffectRef } destroy(): void { - if (this.#currentRenderEffectRef) { - this.#currentRenderEffectRef.destroy() - this.#currentRenderEffectRef = null - } - if (this.#renderView) { - this.#renderView.unmount() - this.#renderView = null + if (this.#renderEffectRef) { + this.#renderEffectRef.destroy() + this.#renderEffectRef = null } + this.#destroyView() } - #update() { - if ( - this.#renderFlags & - (FlexRenderFlags.ContentChanged | FlexRenderFlags.ViewFirstRender) - ) { - this.#render() + #update( + content: FlexRenderTypedContent, + props: TProps, + injector: Injector, + ): void { + if (content.kind === 'null') { + this.#destroyView() + this.#previousProps = props return } - if (this.#renderFlags & FlexRenderFlags.PropsReferenceChanged) { - if (this.#renderView) this.#renderView.updateProps(this.#props()) - this.#renderFlags &= ~FlexRenderFlags.PropsReferenceChanged + const parentInjector = + content.kind === 'flexRenderComponent' + ? (content.content.injector ?? injector) + : injector + const renderView = this.#renderView + + if (!renderView || !renderView.eq(content)) { + this.#render(content, props, parentInjector) + return } - if (this.#renderFlags & FlexRenderFlags.Dirty) { - if (this.#renderView) this.#renderView.dirtyCheck() - this.#renderFlags &= ~FlexRenderFlags.Dirty + const propsChanged = this.#previousProps !== props + renderView.content = content + if (propsChanged) { + renderView.updateProps(props) } + renderView.dirtyCheck() + this.#previousProps = props } - #render() { - // When the view is recreated from scratch (content change or first render), - // we have to destroy the current effect listener since it will be recreated - // skipping the first call (FlexRenderFlags.RenderEffectChecked) - if (this.#shouldRecreateEntireView() && this.#currentRenderEffectRef) { - this.#currentRenderEffectRef.destroy() - this.#currentRenderEffectRef = null - this.#renderFlags &= ~FlexRenderFlags.RenderEffectChecked - } + #render( + content: Exclude, + props: TProps, + parentInjector: Injector, + ): void { + this.#destroyView() + this.#renderView = this.#renderViewByContent(content, props, parentInjector) + this.#previousProps = props + } - this.#viewContainerRef.clear() + #destroyView(): void { if (this.#renderView) { this.#renderView.unmount() this.#renderView = null } - - this.#renderFlags = - (this.#renderFlags & FlexRenderFlags.ViewFirstRender) | - (this.#renderFlags & FlexRenderFlags.RenderEffectChecked) - - const resolvedContent = this.#getContentValue() - this.#renderView = this.#renderViewByContent(resolvedContent) - // If the content is a function `content(props)`, we initialize an effect - // to react to changes. If the current fn uses signals, we will set the DirtySignal flag - // to re-schedule the component updates - if ( - !this.#currentRenderEffectRef && - typeof untracked(this.#content) === 'function' - ) { - this.#currentRenderEffectRef = effect( - () => { - this.#latestContent() - if (!(this.#renderFlags & FlexRenderFlags.RenderEffectChecked)) { - this.#renderFlags |= FlexRenderFlags.RenderEffectChecked - return - } - this.#renderFlags |= FlexRenderFlags.Dirty - this.#doCheck() - }, - { injector: this.#viewContainerRef.injector }, - ) - } - } - - #shouldRecreateEntireView() { - return ( - this.#renderFlags & - FlexRenderFlags.ContentChanged & - FlexRenderFlags.ViewFirstRender - ) - } - - #doCheck() { - const latestContent = this.#getContentValue() - if (latestContent.kind === 'null' || !this.#renderView) { - this.#renderFlags |= FlexRenderFlags.ContentChanged - } else { - const { kind: currentKind } = this.#renderView.content - if ( - latestContent.kind !== currentKind || - !this.#renderView.eq(latestContent) - ) { - this.#renderFlags |= FlexRenderFlags.ContentChanged - } - this.#renderView.content = latestContent - } - this.#update() } #renderViewByContent( - content: FlexRenderTypedContent, + content: Exclude, + props: TProps, + parentInjector: Injector, ): FlexRenderView | null { if (content.kind === 'primitive') { return this.#renderStringContent(content) } else if (content.kind === 'templateRef') { - return this.#renderTemplateRefContent(content) + return this.#renderTemplateRefContent(content, props, parentInjector) } else if (content.kind === 'flexRenderComponent') { - return this.#renderComponent(content) - } else if (content.kind === 'component') { - return this.#renderCustomComponent(content) - } else { - return null + return this.#renderComponent(content, parentInjector) } + return this.#renderCustomComponent(content, props, parentInjector) } #renderStringContent( template: Extract, ): FlexRenderTemplateView { - const context = () => { - const content = this.#content() - return typeof content === 'string' || typeof content === 'number' - ? content - : runInInjectionContext(this.#injector(), () => - content?.(this.#props()), - ) - } const ref = this.#viewContainerRef.createEmbeddedView(this.#templateRef, { - get $implicit() { - return context() - }, + $implicit: template.content, }) return new FlexRenderTemplateView(template, ref) } #renderTemplateRefContent( template: Extract, + props: TProps, + parentInjector: Injector, ): FlexRenderTemplateView { - const latestContext = () => this.#props() const view = this.#viewContainerRef.createEmbeddedView( template.content, - { - get $implicit() { - return latestContext() - }, - }, - { injector: this.#getInjector() }, + { $implicit: props }, + { injector: this.#getInjector(parentInjector) }, ) return new FlexRenderTemplateView(template, view) } @@ -332,9 +258,9 @@ export class FlexViewRenderer< FlexRenderTypedContent, { kind: 'flexRenderComponent' } >, + parentInjector: Injector, ): FlexRenderComponentView { - const { injector } = flexRenderComponent.content - const componentInjector = this.#getInjector(injector) + const componentInjector = this.#getInjector(parentInjector) const view = this.#flexRenderComponentFactory.createComponent( flexRenderComponent.content, componentInjector, @@ -344,11 +270,13 @@ export class FlexViewRenderer< #renderCustomComponent( component: Extract, + props: TProps, + parentInjector: Injector, ): FlexRenderComponentView { const instance = flexRenderComponent(component.content, { - inputs: this.#props(), + inputs: props, }) - const injector = this.#getInjector(instance.injector) + const injector = this.#getInjector(parentInjector) const view = this.#flexRenderComponentFactory.createComponent( instance, injector, @@ -356,7 +284,7 @@ export class FlexViewRenderer< return new FlexRenderComponentView(component, view) } - #getInjector(parentInjector?: Injector) { + #getInjector(parentInjector: Injector) { const getContext = () => this.#props() const proxy = new Proxy(this.#props(), { get: (_, key) => getContext()[key as keyof typeof _], @@ -383,7 +311,7 @@ export class FlexViewRenderer< } return Injector.create({ - parent: parentInjector ?? this.#injector(), + parent: parentInjector, providers: [ ...staticProviders, { provide: FlexRenderComponentProps, useValue: proxy }, diff --git a/packages/angular-table/src/flex-render/view.ts b/packages/angular-table/src/flex-render/view.ts index 39e93c2675..26fae5b640 100644 --- a/packages/angular-table/src/flex-render/view.ts +++ b/packages/angular-table/src/flex-render/view.ts @@ -78,8 +78,6 @@ export abstract class FlexRenderView< abstract dirtyCheck(): void - abstract onDestroy(callback: Function): void - abstract eq(view: TContent): boolean abstract unmount(): void @@ -106,26 +104,30 @@ export class FlexRenderTemplateView extends FlexRenderView< } override updateProps(_props: Record) { - this.view.markForCheck() + if (this.content.kind === 'templateRef') { + const context = this.view.context as { $implicit: unknown } + context.$implicit = _props + this.view.markForCheck() + } } override dirtyCheck() { - // Basically a no-op. When the view is created via EmbeddedViewRef, we don't need to do any manual update - // since this type of content has a proxy as a context, then every time the root component is checked for changes, - // the property getter will be re-evaluated. - // - // If in a future we need to manually mark the view as dirty, just uncomment next line - // this.view.markForCheck() + if (this.content.kind !== 'primitive') return + + const context = this.view.context as { $implicit: unknown } + context.$implicit = this.content.content + if ( + this.previousContent.kind !== 'primitive' || + !Object.is(this.previousContent.content, this.content.content) + ) { + this.view.markForCheck() + } } override unmount() { this.view.destroy() } - override onDestroy(callback: Function) { - this.view.onDestroy(callback) - } - override eq( compare: Extract< FlexRenderTypedContent, @@ -133,9 +135,7 @@ export class FlexRenderTemplateView extends FlexRenderView< >, ): boolean { return ( - (this.content.kind === 'primitive' && - compare.kind === 'primitive' && - this.content.content === compare.content) || + (this.content.kind === 'primitive' && compare.kind === 'primitive') || (this.content.kind === 'templateRef' && compare.kind === 'templateRef' && this.content.content === compare.content) @@ -166,12 +166,12 @@ export class FlexRenderComponentView extends FlexRenderView< override updateProps(props: Record) { switch (this.content.kind) { case 'component': { - this.view.setInputs(props) + this.view.updateInputs(props) break } case 'flexRenderComponent': { - // No-op. When FlexRenderFlags.PropsReferenceChanged is set, - // FlexRenderComponent will be updated into `dirtyCheck`. + // Wrapper inputs and outputs come from the newly resolved content and + // are synchronized in `dirtyCheck`. break } } @@ -187,8 +187,7 @@ export class FlexRenderComponentView extends FlexRenderView< break } case 'flexRenderComponent': { - // Given context instance will always have a different reference than the previous one, - // so instead of recreating the entire view, we will only update the current view + // Reuse the component and synchronize only changed inputs and outputs. if (this.view.eqType(this.content.content)) { this.view.update(this.content.content) } @@ -202,10 +201,6 @@ export class FlexRenderComponentView extends FlexRenderView< this.view.componentRef.destroy() } - override onDestroy(callback: Function) { - this.view.componentRef.onDestroy(callback) - } - override eq( compare: Extract< FlexRenderTypedContent, @@ -218,7 +213,7 @@ export class FlexRenderComponentView extends FlexRenderView< this.content.content === compare.content) || (this.content.kind === 'flexRenderComponent' && compare.kind === 'flexRenderComponent' && - this.content.content.component === compare.content.component) + this.view.canReuse(compare.content)) ) } } diff --git a/packages/angular-table/src/helpers/flexRenderCell.ts b/packages/angular-table/src/helpers/flexRenderCell.ts index 20f3a432f3..b96f02952d 100644 --- a/packages/angular-table/src/helpers/flexRenderCell.ts +++ b/packages/angular-table/src/helpers/flexRenderCell.ts @@ -130,12 +130,9 @@ export class FlexRenderCell< readonly #viewContainerRef = inject(ViewContainerRef) constructor() { - const content = computed(() => this.#renderData()[0]) - const props = computed(() => this.#renderData()[1]) - const renderer = new FlexViewRenderer({ - content: content, - props: props, + content: () => this.#renderData()[0], + props: () => this.#renderData()[1], injector: () => this.#injector, templateRef: this.#templateRef, viewContainerRef: this.#viewContainerRef, diff --git a/packages/angular-table/src/injectTable.ts b/packages/angular-table/src/injectTable.ts index 3486e93f7c..9cfeaca45a 100644 --- a/packages/angular-table/src/injectTable.ts +++ b/packages/angular-table/src/injectTable.ts @@ -99,14 +99,15 @@ export function injectTable< return ngZone.runOutsideAngular(() => lazyInit(() => { + const initialOptions = options() // Explicit type arguments skip generic inference from the spread object // (a type-check hot spot); the spread only adds the angular reactivity // binding to `features`. const table = constructTable({ - ...options(), + ...initialOptions, features: { coreReactivityFeature: angularReactivity(injector), - ...options().features, + ...initialOptions.features, }, }) diff --git a/packages/angular-table/src/reactivity.ts b/packages/angular-table/src/reactivity.ts index d35e0269ce..581e07f13e 100644 --- a/packages/angular-table/src/reactivity.ts +++ b/packages/angular-table/src/reactivity.ts @@ -21,9 +21,7 @@ function signalToReadonlyAtom( get: () => signal(), subscribe: (observer: Observer) => { return untracked(() => - toObservable(computed(signal), { injector: injector }).subscribe( - observer, - ), + toObservable(signal, { injector: injector }).subscribe(observer), ) }, }) @@ -47,9 +45,7 @@ function signalToWritableAtom( get: () => signal(), subscribe: (observer: Observer) => { return untracked(() => - toObservable(computed(signal), { injector: injector }).subscribe( - observer, - ), + toObservable(signal, { injector: injector }).subscribe(observer), ) }, }) diff --git a/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts b/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts index 698ec30ad7..638f90e400 100644 --- a/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts +++ b/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts @@ -12,4 +12,10 @@ test('Infer component inputs', () => { // Input is optional so we can skip passing the property flexRenderComponent(Test, { inputs: {} }) + + flexRenderComponent(Test, { key: 'stable-key' }) + flexRenderComponent(Test, { key: 1 }) + + // @ts-expect-error Keys must have stable primitive identity + flexRenderComponent(Test, { key: {} }) }) diff --git a/packages/angular-table/tests/flex-render/flex-render.bench.ts b/packages/angular-table/tests/flex-render/flex-render.bench.ts new file mode 100644 index 0000000000..76e8a02a1c --- /dev/null +++ b/packages/angular-table/tests/flex-render/flex-render.bench.ts @@ -0,0 +1,239 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, + signal, +} from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { bench, describe } from 'vitest' +import { + FlexRender, + flexRenderComponent, + injectTable, + stockFeatures, +} from '../../src' +import type { ColumnDef } from '../../src' + +const benchmarkOptions = { time: 2_000, warmupTime: 500 } + +@Component({ + template: ` + {{ tick() }} + @for (item of items; track item) { + + {{ value }} + + } + `, + imports: [FlexRender], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class PrimitiveTable { + readonly items = Array.from({ length: 500 }, (_, index) => index) + readonly value = signal('value') + readonly tick = signal(0) + readonly context = {} + readonly render = () => this.value() +} + +@Component({ + template: ``, +}) +class RenderedComponent {} + +describe('flexRender hot paths', () => { + const fixture = TestBed.createComponent(PrimitiveTable) + fixture.detectChanges() + + bench( + 'unrelated change detection for 500 primitive cells', + () => { + fixture.componentInstance.tick.update((value) => value + 1) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'create 500 component render descriptors', + () => { + for (let index = 0; index < 500; index++) { + flexRenderComponent(RenderedComponent) + } + }, + benchmarkOptions, + ) +}) + +interface BenchmarkRow { + id: string + values: Array +} + +const rowCount = 100 +const columnCount = 12 +const largeTableData: Array = Array.from( + { length: rowCount }, + (_, rowIndex) => ({ + id: `row-${rowIndex}`, + values: Array.from( + { length: columnCount }, + (_, columnIndex) => `${rowIndex}:${columnIndex}`, + ), + }), +) +const handleActivate = () => {} + +@Component({ + selector: 'benchmark-cell-a', + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class BenchmarkCellA { + readonly value = input.required() + readonly version = input.required() + readonly activate = output() +} + +@Component({ + selector: 'benchmark-cell-b', + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class BenchmarkCellB { + readonly value = input.required() + readonly version = input.required() + readonly activate = output() +} + +@Component({ + template: ` + {{ hostTick() }} + + + @for (row of table.getRowModel().rows; track row.id) { + + @for (cell of row.getVisibleCells(); track cell.id) { + + } + + } + +
+ + {{ value }} + +
+ `, + imports: [FlexRender], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class LargeMixedTable { + readonly hostTick = signal(0) + readonly valueVersion = signal(0) + readonly componentKind = signal<'a' | 'b'>('a') + readonly contentKind = signal<'primitive' | 'component'>('primitive') + + readonly columns: Array> = + Array.from({ length: columnCount }, (_, columnIndex) => ({ + id: `column-${columnIndex}`, + accessorFn: (row) => row.values[columnIndex]!, + cell: (context) => { + const value = context.getValue() + + // Four primitive columns whose values change in place. + if (columnIndex < 4) { + return `${value}:${this.valueVersion()}` + } + + // Four stable component columns whose inputs change frequently. + if (columnIndex < 8) { + const component = + columnIndex % 2 === 0 ? BenchmarkCellA : BenchmarkCellB + return flexRenderComponent(component, { + inputs: { value, version: this.valueVersion() }, + outputs: { activate: handleActivate }, + }) + } + + // Two columns that intentionally replace component A with component B. + if (columnIndex < 10) { + const component = + this.componentKind() === 'a' ? BenchmarkCellA : BenchmarkCellB + return flexRenderComponent(component, { + inputs: { value, version: 0 }, + outputs: { activate: handleActivate }, + }) + } + + // Two columns that cross the primitive/component view boundary. + return this.contentKind() === 'primitive' + ? value + : flexRenderComponent(BenchmarkCellA, { + inputs: { value, version: 0 }, + outputs: { activate: handleActivate }, + }) + }, + })) + + readonly table = injectTable(() => ({ + data: largeTableData, + columns: this.columns, + features: stockFeatures, + getRowId: (row) => row.id, + })) +} + +describe('flexRender large mixed table', () => { + const fixture = TestBed.createComponent(LargeMixedTable) + fixture.detectChanges() + + const instance = fixture.componentInstance + const renderedCellCount = fixture.nativeElement.querySelectorAll('td').length + if (renderedCellCount !== rowCount * columnCount) { + throw new Error(`Expected 1,200 cells, rendered ${renderedCellCount}`) + } + + bench( + 'unrelated host change with 1,200 mounted cells', + () => { + instance.hostTick.update((value) => value + 1) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'update 400 primitives and 400 stable component inputs', + () => { + instance.valueVersion.update((value) => value + 1) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'replace 200 component A/B cell views', + () => { + instance.componentKind.update((value) => (value === 'a' ? 'b' : 'a')) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'switch 200 cells between primitive and component views', + () => { + instance.contentKind.update((value) => + value === 'primitive' ? 'component' : 'primitive', + ) + fixture.detectChanges() + }, + benchmarkOptions, + ) +}) diff --git a/packages/angular-table/tests/flex-render/flex-render.unit.test.ts b/packages/angular-table/tests/flex-render/flex-render.unit.test.ts index 34c1c07797..e32d5eddbe 100644 --- a/packages/angular-table/tests/flex-render/flex-render.unit.test.ts +++ b/packages/angular-table/tests/flex-render/flex-render.unit.test.ts @@ -1,19 +1,15 @@ -import { - Component, - input, - signal, - ViewChild, - type TemplateRef, -} from '@angular/core' -import { TestBed, type ComponentFixture } from '@angular/core/testing' -import { describe, expect, test } from 'vitest' +import { Component, ViewChild, input, output, signal } from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { describe, expect, test, vi } from 'vitest' import { FlexRender, - flexRenderComponent, FlexRenderDirective, + flexRenderComponent, injectFlexRenderContext, } from '../../src' import { setFixtureSignalInput, setFixtureSignalInputs } from '../test-utils' +import type { ComponentFixture } from '@angular/core/testing' +import type { TemplateRef } from '@angular/core' describe('FlexRenderDirective', () => { test('should render primitives', () => { @@ -62,6 +58,34 @@ describe('FlexRenderDirective', () => { expect((fixture.nativeElement as HTMLElement).matches(':empty')).toBe(true) }) + test('should evaluate and update primitive content only when its dependencies change', () => { + const value = signal('Initial value') + const render = vi.fn(() => value()) + const fixture = TestBed.createComponent(TestRenderComponent) + + setFixtureSignalInputs(fixture, { + content: render, + context: {}, + }) + + const initialSpan = fixture.nativeElement.querySelector('span') + expect(render).toHaveBeenCalledTimes(1) + expect(initialSpan.textContent).toEqual('Initial value') + + fixture.detectChanges() + fixture.detectChanges() + + expect(render).toHaveBeenCalledTimes(1) + expect(fixture.nativeElement.querySelector('span')).toBe(initialSpan) + + value.set('Updated value') + fixture.detectChanges() + + expect(render).toHaveBeenCalledTimes(2) + expect(fixture.nativeElement.querySelector('span')).toBe(initialSpan) + expect(initialSpan.textContent).toEqual('Updated value') + }) + test('should render TemplateRef', () => { @Component({ template: ` @@ -122,6 +146,115 @@ describe('FlexRenderDirective', () => { expect(fixture.nativeElement.textContent).toEqual('Updated value') }) + test('should release and restore component output subscriptions', () => { + @Component({ + template: ``, + standalone: true, + }) + class FakeComponent { + readonly changed = output() + } + + const enabled = signal(true) + const listener = vi.fn() + const fixture = TestBed.createComponent(TestRenderComponent) + + setFixtureSignalInputs(fixture, { + content: () => + flexRenderComponent(FakeComponent, { + outputs: enabled() ? { changed: listener } : {}, + }), + context: {}, + }) + + const button = fixture.nativeElement.querySelector( + 'button', + ) as HTMLButtonElement + button.click() + expect(listener).toHaveBeenCalledTimes(1) + + enabled.set(false) + fixture.detectChanges() + button.click() + expect(listener).toHaveBeenCalledTimes(1) + + enabled.set(true) + fixture.detectChanges() + button.click() + expect(listener).toHaveBeenCalledTimes(2) + expect(fixture.nativeElement.querySelector('button')).toBe(button) + }) + + test('should set component inputs by property name when they have an alias', () => { + @Component({ + template: `{{ value() }}`, + standalone: true, + }) + class FakeComponent { + readonly value = input('', { alias: 'aliasedValue' }) + } + + const fixture = TestBed.createComponent(TestRenderComponent) + setFixtureSignalInputs(fixture, { + content: () => + flexRenderComponent(FakeComponent, { + inputs: { value: 'Aliased input value' }, + }), + context: {}, + }) + + expect(fixture.nativeElement.textContent).toEqual('Aliased input value') + }) + + test('should reuse a component by type and key and recreate it when the key changes', () => { + @Component({ + selector: 'app-keyed-component', + template: `{{ value() }}`, + standalone: true, + }) + class KeyedComponent { + readonly value = input.required() + } + + const key = signal('first') + const value = signal('Initial value') + const fixture = TestBed.createComponent(TestRenderComponent) + + setFixtureSignalInputs(fixture, { + content: () => + flexRenderComponent(KeyedComponent, { + key: key(), + inputs: { value: value() }, + // These creation-time arrays are intentionally recreated whenever + // the render function runs. They do not affect reuse without a new key. + bindings: [], + directives: [], + }), + context: {}, + }) + + const initialHost = fixture.nativeElement.querySelector( + 'app-keyed-component', + ) + expect(initialHost.textContent).toEqual('Initial value') + + value.set('Updated value') + fixture.detectChanges() + + expect(fixture.nativeElement.querySelector('app-keyed-component')).toBe( + initialHost, + ) + expect(initialHost.textContent).toEqual('Updated value') + + key.set(2) + fixture.detectChanges() + + expect(fixture.nativeElement.querySelector('app-keyed-component')).not.toBe( + initialHost, + ) + expect(fixture.nativeElement.textContent).toEqual('Updated value') + }) + test('should rerender when content has conditional return with different component types', () => { @Component({ selector: 'app-fake-a', diff --git a/packages/angular-table/tests/injectTable.test.ts b/packages/angular-table/tests/injectTable.test.ts index aee244543d..20b84a7af9 100644 --- a/packages/angular-table/tests/injectTable.test.ts +++ b/packages/angular-table/tests/injectTable.test.ts @@ -17,6 +17,21 @@ import { injectTable } from '../src' import type { PaginationState } from '../src' describe('injectTable', () => { + test('evaluates options once while constructing the table', () => { + const options = vi.fn(() => ({ + data: [], + features: stockFeatures, + columns: [], + })) + const table = TestBed.runInInjectionContext(() => injectTable(options)) + + expect(options).not.toHaveBeenCalled() + + void table.options + + expect(options).toHaveBeenCalledTimes(1) + }) + test('should support required signal inputs', async () => { type Data = { id: string; title: string } From ba686fe695c1507f669e8afe810410f8b085abc5 Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Fri, 7 Aug 2026 01:26:00 +0200 Subject: [PATCH 02/23] trading grid example --- examples/angular/realtime-trading/README.md | 285 +++++++ .../angular/realtime-trading/angular.json | 76 ++ .../angular/realtime-trading/package.json | 32 + .../realtime-trading/src/app/app.config.ts | 6 + .../angular/realtime-trading/src/app/app.ts | 69 ++ .../src/app/benchmark-profiles.ts | 65 ++ .../src/app/benchmark/benchmark-monitor.ts | 186 +++++ .../src/app/beta-trading-table.ts | 43 + .../app/core/trading-benchmark.controller.ts | 290 +++++++ .../src/app/current-trading-table.ts | 43 + .../realtime-trading/src/app/market-data.ts | 38 + .../src/app/market-feed-engine.ts | 173 ++++ .../src/app/market-feed-protocol.ts | 66 ++ .../src/app/market-feed.worker.ts | 133 +++ .../realtime-trading/src/app/quote-cells.ts | 258 ++++++ .../src/app/shell/configurator.html | 169 ++++ .../src/app/shell/configurator.ts | 46 ++ .../src/app/shell/diagnostics.ts | 71 ++ .../src/app/shell/market-statusbar.ts | 38 + .../src/app/shell/market-toolbar.ts | 59 ++ .../src/app/shell/metrics-strip.ts | 74 ++ .../src/app/shell/selected-instrument.ts | 36 + .../src/app/shell/shell-formatters.ts | 20 + .../src/app/shell/shell-header.ts | 36 + .../src/app/shell/trading-shell.html | 20 + .../src/app/shell/trading-shell.ts | 23 + .../src/app/table-row-model.worker.ts | 44 + .../realtime-trading/src/app/table-v8.html | 48 ++ .../realtime-trading/src/app/table-v9.html | 40 + .../src/app/trading-column-types.ts | 10 + .../src/app/trading-columns-beta.ts | 168 ++++ .../src/app/trading-columns-v8.ts | 165 ++++ .../src/app/trading-columns.ts | 165 ++++ .../src/app/v8-trading-table.ts | 43 + .../src/app/worker-trading-table.ts | 79 ++ .../angular/realtime-trading/src/index.html | 16 + examples/angular/realtime-trading/src/main.ts | 5 + .../angular/realtime-trading/src/styles.css | 724 ++++++++++++++++ .../realtime-trading/tests/e2e/smoke.spec.ts | 139 ++++ .../realtime-trading/tsconfig.app.json | 9 + .../angular/realtime-trading/tsconfig.json | 23 + examples/react/realtime-trading/README.md | 324 ++++++++ examples/react/realtime-trading/index.html | 17 + examples/react/realtime-trading/package.json | 32 + examples/react/realtime-trading/src/App.tsx | 41 + .../src/benchmark-profiles.ts | 65 ++ .../src/benchmark/benchmark-monitor.ts | 445 ++++++++++ .../src/benchmark/use-table-benchmark.ts | 115 +++ .../src/core/trading-benchmark-controller.ts | 532 ++++++++++++ .../core/use-trading-benchmark-controller.ts | 15 + .../src/core/use-trading-table-runtime.ts | 40 + examples/react/realtime-trading/src/index.css | 732 +++++++++++++++++ examples/react/realtime-trading/src/main.tsx | 10 + .../react/realtime-trading/src/market-data.ts | 38 + .../src/market-feed-engine.ts | 173 ++++ .../src/market-feed-protocol.ts | 66 ++ .../src/market-feed.worker.ts | 133 +++ .../realtime-trading/src/quote-cells.tsx | 190 +++++ .../src/shell/TradingShell.tsx | 775 ++++++++++++++++++ .../src/shell/trading-shell-context.tsx | 33 + .../src/trading-table-local.tsx | 144 ++++ .../src/trading-table-shared.tsx | 317 +++++++ .../realtime-trading/src/trading-table-v8.tsx | 77 ++ .../realtime-trading/src/trading-table.tsx | 15 + .../react/realtime-trading/src/vite-env.d.ts | 1 + .../realtime-trading/tests/e2e/smoke.spec.ts | 170 ++++ examples/react/realtime-trading/tsconfig.json | 20 + .../react/realtime-trading/vite.config.ts | 42 + examples/solid/realtime-trading/README.md | 208 +++++ examples/solid/realtime-trading/index.html | 16 + examples/solid/realtime-trading/package.json | 24 + examples/solid/realtime-trading/src/App.tsx | 50 ++ .../src/benchmark-profiles.ts | 65 ++ .../src/benchmark/benchmark-monitor.ts | 286 +++++++ .../src/core/trading-benchmark-controller.ts | 300 +++++++ examples/solid/realtime-trading/src/index.css | 722 ++++++++++++++++ examples/solid/realtime-trading/src/index.tsx | 8 + .../solid/realtime-trading/src/market-data.ts | 38 + .../src/market-feed-engine.ts | 169 ++++ .../src/market-feed-protocol.ts | 66 ++ .../src/market-feed.worker.ts | 133 +++ .../realtime-trading/src/quote-cells.tsx | 201 +++++ .../src/shell/TradingShell.tsx | 574 +++++++++++++ .../src/shell/trading-shell-context.tsx | 26 + .../realtime-trading/src/trading-table.tsx | 339 ++++++++ .../solid/realtime-trading/src/vite-env.d.ts | 1 + .../realtime-trading/tests/e2e/smoke.spec.ts | 104 +++ examples/solid/realtime-trading/tsconfig.json | 21 + .../solid/realtime-trading/vite.config.ts | 13 + package.json | 6 + pnpm-lock.yaml | 180 ++++ pnpm-workspace.yaml | 6 + 92 files changed, 12151 insertions(+) create mode 100644 examples/angular/realtime-trading/README.md create mode 100644 examples/angular/realtime-trading/angular.json create mode 100644 examples/angular/realtime-trading/package.json create mode 100644 examples/angular/realtime-trading/src/app/app.config.ts create mode 100644 examples/angular/realtime-trading/src/app/app.ts create mode 100644 examples/angular/realtime-trading/src/app/benchmark-profiles.ts create mode 100644 examples/angular/realtime-trading/src/app/benchmark/benchmark-monitor.ts create mode 100644 examples/angular/realtime-trading/src/app/beta-trading-table.ts create mode 100644 examples/angular/realtime-trading/src/app/core/trading-benchmark.controller.ts create mode 100644 examples/angular/realtime-trading/src/app/current-trading-table.ts create mode 100644 examples/angular/realtime-trading/src/app/market-data.ts create mode 100644 examples/angular/realtime-trading/src/app/market-feed-engine.ts create mode 100644 examples/angular/realtime-trading/src/app/market-feed-protocol.ts create mode 100644 examples/angular/realtime-trading/src/app/market-feed.worker.ts create mode 100644 examples/angular/realtime-trading/src/app/quote-cells.ts create mode 100644 examples/angular/realtime-trading/src/app/shell/configurator.html create mode 100644 examples/angular/realtime-trading/src/app/shell/configurator.ts create mode 100644 examples/angular/realtime-trading/src/app/shell/diagnostics.ts create mode 100644 examples/angular/realtime-trading/src/app/shell/market-statusbar.ts create mode 100644 examples/angular/realtime-trading/src/app/shell/market-toolbar.ts create mode 100644 examples/angular/realtime-trading/src/app/shell/metrics-strip.ts create mode 100644 examples/angular/realtime-trading/src/app/shell/selected-instrument.ts create mode 100644 examples/angular/realtime-trading/src/app/shell/shell-formatters.ts create mode 100644 examples/angular/realtime-trading/src/app/shell/shell-header.ts create mode 100644 examples/angular/realtime-trading/src/app/shell/trading-shell.html create mode 100644 examples/angular/realtime-trading/src/app/shell/trading-shell.ts create mode 100644 examples/angular/realtime-trading/src/app/table-row-model.worker.ts create mode 100644 examples/angular/realtime-trading/src/app/table-v8.html create mode 100644 examples/angular/realtime-trading/src/app/table-v9.html create mode 100644 examples/angular/realtime-trading/src/app/trading-column-types.ts create mode 100644 examples/angular/realtime-trading/src/app/trading-columns-beta.ts create mode 100644 examples/angular/realtime-trading/src/app/trading-columns-v8.ts create mode 100644 examples/angular/realtime-trading/src/app/trading-columns.ts create mode 100644 examples/angular/realtime-trading/src/app/v8-trading-table.ts create mode 100644 examples/angular/realtime-trading/src/app/worker-trading-table.ts create mode 100644 examples/angular/realtime-trading/src/index.html create mode 100644 examples/angular/realtime-trading/src/main.ts create mode 100644 examples/angular/realtime-trading/src/styles.css create mode 100644 examples/angular/realtime-trading/tests/e2e/smoke.spec.ts create mode 100644 examples/angular/realtime-trading/tsconfig.app.json create mode 100644 examples/angular/realtime-trading/tsconfig.json create mode 100644 examples/react/realtime-trading/README.md create mode 100644 examples/react/realtime-trading/index.html create mode 100644 examples/react/realtime-trading/package.json create mode 100644 examples/react/realtime-trading/src/App.tsx create mode 100644 examples/react/realtime-trading/src/benchmark-profiles.ts create mode 100644 examples/react/realtime-trading/src/benchmark/benchmark-monitor.ts create mode 100644 examples/react/realtime-trading/src/benchmark/use-table-benchmark.ts create mode 100644 examples/react/realtime-trading/src/core/trading-benchmark-controller.ts create mode 100644 examples/react/realtime-trading/src/core/use-trading-benchmark-controller.ts create mode 100644 examples/react/realtime-trading/src/core/use-trading-table-runtime.ts create mode 100644 examples/react/realtime-trading/src/index.css create mode 100644 examples/react/realtime-trading/src/main.tsx create mode 100644 examples/react/realtime-trading/src/market-data.ts create mode 100644 examples/react/realtime-trading/src/market-feed-engine.ts create mode 100644 examples/react/realtime-trading/src/market-feed-protocol.ts create mode 100644 examples/react/realtime-trading/src/market-feed.worker.ts create mode 100644 examples/react/realtime-trading/src/quote-cells.tsx create mode 100644 examples/react/realtime-trading/src/shell/TradingShell.tsx create mode 100644 examples/react/realtime-trading/src/shell/trading-shell-context.tsx create mode 100644 examples/react/realtime-trading/src/trading-table-local.tsx create mode 100644 examples/react/realtime-trading/src/trading-table-shared.tsx create mode 100644 examples/react/realtime-trading/src/trading-table-v8.tsx create mode 100644 examples/react/realtime-trading/src/trading-table.tsx create mode 100644 examples/react/realtime-trading/src/vite-env.d.ts create mode 100644 examples/react/realtime-trading/tests/e2e/smoke.spec.ts create mode 100644 examples/react/realtime-trading/tsconfig.json create mode 100644 examples/react/realtime-trading/vite.config.ts create mode 100644 examples/solid/realtime-trading/README.md create mode 100644 examples/solid/realtime-trading/index.html create mode 100644 examples/solid/realtime-trading/package.json create mode 100644 examples/solid/realtime-trading/src/App.tsx create mode 100644 examples/solid/realtime-trading/src/benchmark-profiles.ts create mode 100644 examples/solid/realtime-trading/src/benchmark/benchmark-monitor.ts create mode 100644 examples/solid/realtime-trading/src/core/trading-benchmark-controller.ts create mode 100644 examples/solid/realtime-trading/src/index.css create mode 100644 examples/solid/realtime-trading/src/index.tsx create mode 100644 examples/solid/realtime-trading/src/market-data.ts create mode 100644 examples/solid/realtime-trading/src/market-feed-engine.ts create mode 100644 examples/solid/realtime-trading/src/market-feed-protocol.ts create mode 100644 examples/solid/realtime-trading/src/market-feed.worker.ts create mode 100644 examples/solid/realtime-trading/src/quote-cells.tsx create mode 100644 examples/solid/realtime-trading/src/shell/TradingShell.tsx create mode 100644 examples/solid/realtime-trading/src/shell/trading-shell-context.tsx create mode 100644 examples/solid/realtime-trading/src/trading-table.tsx create mode 100644 examples/solid/realtime-trading/src/vite-env.d.ts create mode 100644 examples/solid/realtime-trading/tests/e2e/smoke.spec.ts create mode 100644 examples/solid/realtime-trading/tsconfig.json create mode 100644 examples/solid/realtime-trading/vite.config.ts diff --git a/examples/angular/realtime-trading/README.md b/examples/angular/realtime-trading/README.md new file mode 100644 index 0000000000..3ec6393e0f --- /dev/null +++ b/examples/angular/realtime-trading/README.md @@ -0,0 +1,285 @@ +# Angular real-time trading flexRender lab + +This example generates deterministic synthetic quote events in the browser. It +is designed to stress Angular Table's `flexRender` paths, not to model an +exchange or display real financial data. + +The workload is inspired by the public +[AG Grid finance demo](https://www.ag-grid.com/example-finance/) and its +[source repository](https://github.com/ag-grid/ag-grid-demos/tree/main/finance), +but is intentionally smaller and focused on Angular `flexRender` lifecycle +behavior rather than matching that demo's features. + +## Run it + +From the repository root: + +```sh +pnpm --filter tanstack-angular-table-example-realtime-trading dev +``` + +For representative measurements, serve the production configuration: + +```sh +pnpm --filter tanstack-angular-table-example-realtime-trading ng serve --configuration production --port 7777 +``` + +Open `http://localhost:7777`. + +## What it exercises + +The feed control has named load profiles so comparisons do not depend on +remembering slider positions: + +- **Low** is 1k events/s. +- **Medium** is 5k events/s. +- **High** is the 10k events/s default. +- **Very high** is 25k events/s. +- **Max** requests 100k events/s and is intentionally a saturation test. +- Moving the rate slider selects **Custom**. + +Available universe sizes are 50, 100, 150, 250, 350, 500, 750, and 1,000. +The intermediate sizes make it easier to locate the point where an adapter +stops meeting its frame or throughput target. + +The row workload selector separates four different costs: + +- **Stable universe** preserves row IDs and source order. +- **Continuously sort by Last** changes input order as prices move but keeps the + same IDs, testing keyed row movement rather than destruction. +- **Rotate 20% filtered rows** excludes one of five index buckets and changes + the excluded bucket once per second, testing removal and reinsertion. +- **Replace 10% of ticker IDs** gives one of ten buckets new IDs and ticker + labels once per second. Ten percent are replacements at any instant; because + the previous bucket returns while the next enters, each transition crosses + lifecycle boundaries for roughly twenty percent of rows. + +These transformations happen before the selected adapter, so every version +receives identical arrays. They test row-model and rendering consequences; they +do not benchmark each version's public sorting or filtering API. + +The configurator can mount three implementations against the same workload: + +- **Local optimized (v9)** uses the adapter and table core from this workspace. +- **Published 9.0.0-beta.80** uses the exact npm release and its matching + `@tanstack/table-core`. +- **Published 8.21.4** uses the final v8 Angular adapter and table core 8.21.3. + +Changing the implementation select destroys the current table component and +mounts the selected one. The current immutable quote array, selected symbol, +renderer mode, and performance counters stay in the parent component, so every +adapter receives the same live state. + +The local v9 adapter also exposes a **Worker row model** checkbox. It replaces +the normal local table with a v9 table using the experimental worker plugin for +the filtered row-model stage. The row-model worker is a second worker, separate +from the market-feed worker. The checkbox is disabled for beta.80 and v8 because +those published versions do not expose this plugin. Turning it off destroys the +worker-backed table and terminates its worker. + +This table currently has no active user filter, so the worker stage returns the +full row order. That is intentional: it isolates the serialization, +postMessage, stale-while-revalidate, and row-model reconstruction overhead under +rapid immutable data replacement. It is not expected to be faster at 250โ€“1,000 +unfiltered rows; the plugin becomes more compelling when expensive filtering, +grouping, or sorting dominates the transfer cost. + +- Quote fields are plain values, matching decoded WebSocket/SSE records rather + than embedding Angular signals in the data model. +- Every worker batch publishes a new data-array reference and recreates each + changed quote object. Unchanged quotes preserve their identity, and + `getRowId` keeps table rows associated with their instruments. +- This deliberately exercises adapter option updates and table row-model + recomputation in addition to `flexRender` input updates. +- A Web Worker owns quote generation, random-walk calculations, event-rate + scheduling, history updates, and burst processing. +- One quote event updates price, bid, ask, direction, and volume; the event + counter therefore represents market messages rather than individual field + changes. +- The market-watch columns use Ticker, Last Qty, Bid / Ask Qty, Day %, Total + Qty, Traded Value, and Intraday terminology. Total Qty and Traded Value are + cumulative synthetic session fields; Last Qty is the most recent trade size. +- Bid, ask, percentage change, quantities, and traded value are primitive + renderers. +- Last price is a stable Angular component whose inputs and output callback are + updated frequently. +- Tick direction can use one stable component or switch between separate up and + down component types. +- Spread components receive bid and ask updates and recompute absolute and + basis-point spreads. +- Depth components receive bid/ask sizes on every quote and redraw a two-sided + liquidity imbalance bar. +- Quote-age components share a 100 ms clock. This intentionally invalidates the + whole Age column at once and can be disabled independently. +- Sparkline components receive new array references at a configurable cadence. +- Component create/destroy counters and the optional Chrome heap estimate help + expose unintended churn or retained component state. + +## Shared code and adapter boundaries + +Most of the example is deliberately shared: + +- `market-feed-engine.ts` is the framework-free quote algorithm that runs in + the worker. +- `market-feed.worker.ts` owns scheduling, batching, coalescing, and + backpressure. +- `market-feed-protocol.ts` defines typed commands and events shared across the + worker boundary. +- `market-data.ts` hydrates initial worker snapshots and immutably recreates + changed application rows on the main thread. +- `table-row-model.worker.ts` hosts the optional v9 shadow table used by the + experimental row-model worker plugin. +- `worker-trading-table.ts` wires the local v9 table to that worker-backed + filtered row model and terminates it when the component is destroyed. +- `quote-cells.ts` owns all dynamic Angular cell components and lifecycle + instrumentation. +- `trading-column-types.ts` contains only the renderer-mode/state contract and + the diagnostics column count. +- `trading-columns.ts` is the local-v9 column factory shared by the current and + worker-backed tables. It calls the local adapter's `flexRenderComponent` + directly only for genuine Angular component cells. +- `trading-columns-beta.ts` and `trading-columns-v8.ts` intentionally duplicate + the complete adapter-specific column configuration, including IDs, labels, + widths, and formatters. Each calls its own package's typed + `flexRenderComponent`; no token spreads, generic renderer callback, or + `unknown` cast crosses the version boundary. Primitive cells continue to + return primitive values. +- `core/trading-benchmark.controller.ts` owns application signals, worker + transport, derived table inputs, and user commands. +- `benchmark/benchmark-monitor.ts` owns browser observers, render + acknowledgements, and published metrics. +- `shell/` contains independent header, toolbar, metrics, status-bar, + diagnostics, selected-instrument, and configurator components. Each injects + the same controller directly. +- `shell/trading-shell.html` is only the layout and `` projection + point. +- `app.ts` owns the projected local/beta/v8 adapter switch. + +Table construction and component-render descriptors are version-specific. The +local and beta v9 components share `table-v9.html` and use `injectTable`, +`stockFeatures`, and the `flexRenderCell` / `flexRenderHeader` shorthand +directives. The v8 component uses `createAngularTable`, `getCoreRowModel`, and +the older direct `flexRender` microsyntax in `table-v8.html`. + +## Architecture + +`App` deliberately has no knowledge of workers, timers, render callbacks, or +adapter commands. It selects the active adapter and projects it through +`TradingShell`. The injected `TradingBenchmarkController` is the single +stateful boundary: it subscribes to the feed worker, converts protocol events +into immutable row snapshots, derives the selected workload, and exposes +signals plus commands to every shell component. + +Benchmark instrumentation is a separate collaborator. `BenchmarkMonitor` +contains the mutable sampling runtime and publishes immutable metric snapshots +back through the controller. This keeps measurement policy out of both the +table adapters and the presentational shell. + +All deliberate mutable runtime is grouped behind `const` object or class +identities. TypeScript source files do not use `let`; counters and handles +change as properties of those stable runtime owners instead of being scattered +mutable bindings. Angular template `@for (...; let index = ...)` declarations, +where present, are template syntax rather than JavaScript mutable bindings. + +The beta and v8 dependencies use exact npm tarball URLs. This is intentional: +the repository globally redirects `@tanstack/angular-table` to the local +workspace package, and a normal npm alias would therefore not provide an +independent published baseline. Version-specific overrides also keep each +adapter paired with the table-core version it was released against. + +## Worker transport and backpressure + +The worker is intentionally shaped like an external market-data transport. The +Angular application sends configuration commands and listens for `ready` and +`batch` messages, much as an application would listen to a WebSocket or SSE +client. + +The worker does not post one browser message for every quote event. It +coalesces all pending changes by instrument and allows only one batch to be in +flight. Angular acknowledges that batch after `afterEveryRender`; only then can +the worker send the next one. While the main thread is busy, the worker keeps +the newest snapshot for each instrument in a bounded map rather than building +an unbounded message queue. + +For that reason, **batch events** and **row updates** are separate diagnostics. +A batch may represent thousands of source events but contain at most one final +update per instrument. The event counter still measures the requested source +load, while row updates describe the amount of data copied and applied by the +UI. + +This protects the main thread from quote-generation work and queue growth, but +it does not make rendering free. Recreating changed rows, publishing the new +data array, Angular change detection, table row-model work, `flexRender`, +layout, and paint must still happen on the main thread. +In a production application, the WebSocket connection and parsing could also +live inside the worker and use this same message protocol. If the WebSocket or +`EventSource` is created in the window instead, its JavaScript event handlers +still run on the main thread. + +## Performance metrics + +The dashboard keeps browser scheduling and Angular work separate: + +- **RAF rate** counts `requestAnimationFrame` callbacks and divides them by the + real wall-clock sampling duration. It is not clamped and therefore includes + long main-thread stalls. It describes browser frame opportunities, not table + renders, and will normally follow the display refresh rate. +- **Table renders** counts worker batches that reach `afterEveryRender` and are + acknowledged back to the worker. This is the UI's actual feed-render + throughput. +- **Average / P95 render** measures worker-message reception through Angular's + completed render callback. It excludes worker calculation, browser paint, + and time coalescing behind an in-flight batch. +- **Long frames** uses a feature-detected `PerformanceObserver` with the + `long-animation-frame` entry type. It counts complete browser animation + frames longer than 50 ms and records the worst duration since reset. +- **Renders over 16.7 ms** remains an application-level diagnostic for the most + recent sample. It assumes a 60 Hz frame budget and is intentionally distinct + from the browser's Long Animation Frames API. + +Long Animation Frames are currently supported by Chromium-based browsers. The +dashboard shows `N/A` when the browser does not expose that performance entry +type. For exact frame, layout, paint, and compositor attribution, record the +same workload in the browser's Performance tools. + +## A repeatable comparison + +1. Use the same production browser build and machine for both commits. +2. Start at 250 instruments, 10k events/s, stable Tick renderer, quote ages, + and sparklines enabled. +3. Select one implementation, reset the session, and let it warm up for 20โ€“30 + seconds. +4. Record actual throughput, RAF rate, table renders/s, P95 render time, long + frames, live components, and heap over a fixed measurement window. +5. Repeat step 3 for the other implementations without changing the controls. +6. Increase the event target until actual throughput cannot keep up or P95 + exceeds the 16.7 ms frame budget. +7. Repeat with component swapping, quote ages, and sparklines toggled + separately. The 25k burst is useful for profiling worker aggregation and one + large coalesced UI update. + +Component create/destroy totals are cumulative across adapter switches. A +switch should destroy the old table's dynamic cell components and create the +new table's components, so both totals jump by design. Likewise, the JS heap +can rise temporarily while the old tree waits for garbage collection. Compare +post-GC heap plateaus after several identical switch cycles; a rising raw heap +line by itself does not prove a leak. + +Immutable batches also allocate a new array plus one object per changed row. +Those short-lived objects are expected garbage. Memory analysis should compare +post-GC plateaus rather than expecting a flat allocation graph. + +The displayed Chrome heap estimate is diagnostic only. Treat the browser's +Memory profiler as the source of truth when separating the main realm, worker +realm, detached DOM, and garbage awaiting collection. + +Because this lab bundles three adapter and table-core generations, its download +size is intentionally larger than a normal application and should not be used +for bundle-size comparison. + +The UI resembles a market-watch blotter, but the feed is deterministic +synthetic data rather than an exchange simulator. A production-confidence test +should replay a timestamped, sanitized capture through the worker protocol so +burstiness and symbol skew are preserved. Use browser Performance and Memory +recordings as the source of truth for scripting, layout, paint, detached nodes, +and post-GC heap; the in-app counters are comparison aids. diff --git a/examples/angular/realtime-trading/angular.json b/examples/angular/realtime-trading/angular.json new file mode 100644 index 0000000000..5512416689 --- /dev/null +++ b/examples/angular/realtime-trading/angular.json @@ -0,0 +1,76 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "pnpm", + "analytics": false, + "cache": { + "enabled": false + } + }, + "newProjectRoot": "projects", + "projects": { + "realtime-trading": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "inlineTemplate": true, + "inlineStyle": true, + "skipTests": true + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "tsConfig": "tsconfig.app.json", + "assets": [], + "styles": ["src/styles.css"] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "options": { + "allowedHosts": true + }, + "configurations": { + "production": { + "buildTarget": "realtime-trading:build:production" + }, + "development": { + "buildTarget": "realtime-trading:build:development" + } + }, + "defaultConfiguration": "development" + } + } + } + } +} diff --git a/examples/angular/realtime-trading/package.json b/examples/angular/realtime-trading/package.json new file mode 100644 index 0000000000..7156570054 --- /dev/null +++ b/examples/angular/realtime-trading/package.json @@ -0,0 +1,32 @@ +{ + "name": "tanstack-angular-table-example-realtime-trading", + "scripts": { + "ng": "ng", + "start": "ng serve --port 7777", + "dev": "ng serve --port 7777", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "lint": "eslint ./src", + "test:types": "tsc -p tsconfig.app.json --noEmit", + "test:e2e": "PLAYWRIGHT_TEST_DIR=$PWD/tests/e2e playwright test --config ../../../playwright.config.ts" + }, + "private": true, + "packageManager": "pnpm@11.18.0", + "dependencies": { + "@angular/common": "^22.1.0", + "@angular/compiler": "^22.1.0", + "@angular/core": "^22.1.0", + "@angular/platform-browser": "^22.1.0", + "@tanstack/angular-table": "^9.0.0", + "@tanstack/angular-table-beta": "https://registry.npmjs.org/@tanstack/angular-table/-/angular-table-9.0.0-beta.80.tgz", + "@tanstack/angular-table-v8": "https://registry.npmjs.org/@tanstack/angular-table/-/angular-table-8.21.4.tgz", + "rxjs": "~7.8.2", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@angular/build": "^22.1.2", + "@angular/cli": "^22.1.2", + "@angular/compiler-cli": "^22.1.0", + "typescript": "6.0.3" + } +} diff --git a/examples/angular/realtime-trading/src/app/app.config.ts b/examples/angular/realtime-trading/src/app/app.config.ts new file mode 100644 index 0000000000..cbb47d366c --- /dev/null +++ b/examples/angular/realtime-trading/src/app/app.config.ts @@ -0,0 +1,6 @@ +import { provideBrowserGlobalErrorListeners } from '@angular/core' +import type { ApplicationConfig } from '@angular/core' + +export const appConfig: ApplicationConfig = { + providers: [provideBrowserGlobalErrorListeners()], +} diff --git a/examples/angular/realtime-trading/src/app/app.ts b/examples/angular/realtime-trading/src/app/app.ts new file mode 100644 index 0000000000..553d3dad06 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/app.ts @@ -0,0 +1,69 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { BetaTradingTable } from './beta-trading-table' +import { TradingBenchmarkController } from './core/trading-benchmark.controller' +import { CurrentTradingTable } from './current-trading-table' +import { TradingShell } from './shell/trading-shell' +import { V8TradingTable } from './v8-trading-table' +import { WorkerTradingTable } from './worker-trading-table' + +@Component({ + selector: 'app-root', + imports: [ + BetaTradingTable, + CurrentTradingTable, + TradingShell, + V8TradingTable, + WorkerTradingTable, + ], + template: ` + + @switch (controller.tableAdapter()) { + @case ('local') { + @if (controller.tableWorkerEnabled()) { + + } @else { + + } + } + @case ('beta') { + + } + @case ('v8') { + + } + } + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class App { + readonly controller = inject(TradingBenchmarkController) +} diff --git a/examples/angular/realtime-trading/src/app/benchmark-profiles.ts b/examples/angular/realtime-trading/src/app/benchmark-profiles.ts new file mode 100644 index 0000000000..848fc28880 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/benchmark-profiles.ts @@ -0,0 +1,65 @@ +import type { MarketQuote } from './market-data' + +export type FeedLoadProfile = + 'low' | 'medium' | 'high' | 'very-high' | 'max' | 'custom' + +export type RowWorkloadMode = + 'stable' | 'price-sort' | 'rotating-filter' | 'identity-churn' + +export const feedLoadRates: Record< + Exclude, + number +> = { + low: 1_000, + medium: 5_000, + high: 10_000, + 'very-high': 25_000, + max: 100_000, +} + +export function deriveBenchmarkQuotes( + quotes: Array, + mode: RowWorkloadMode, + epoch: number, +): Array { + if (mode === 'stable') { + return quotes + } + + if (mode === 'price-sort') { + return [...quotes].sort( + (left, right) => + right.price - left.price || left.symbol.localeCompare(right.symbol), + ) + } + + if (mode === 'rotating-filter') { + const excludedBucket = epoch % 5 + return quotes.filter((_, index) => index % 5 !== excludedBucket) + } + + const replacementBucket = epoch % 10 + return quotes.map((quote, index) => + index % 10 === replacementBucket + ? { + ...quote, + id: `${quote.id}-replacement-${epoch}`, + symbol: `${quote.symbol}R${epoch % 100}`, + company: `${quote.company} replacement`, + } + : quote, + ) +} + +export function rowWorkloadLabel(mode: RowWorkloadMode): string { + switch (mode) { + case 'price-sort': + return 'PRICE REORDER' + case 'rotating-filter': + return 'FILTER ROTATION' + case 'identity-churn': + return 'TICKER REPLACEMENT' + default: + return 'STABLE UNIVERSE' + } +} diff --git a/examples/angular/realtime-trading/src/app/benchmark/benchmark-monitor.ts b/examples/angular/realtime-trading/src/app/benchmark/benchmark-monitor.ts new file mode 100644 index 0000000000..d92631820f --- /dev/null +++ b/examples/angular/realtime-trading/src/app/benchmark/benchmark-monitor.ts @@ -0,0 +1,186 @@ +import { quoteCellLifecycle } from '../quote-cells' +import type { MarketFeedCommand } from '../market-feed-protocol' + +export interface FeedMetrics { + actualEventsPerSecond: number + totalEvents: number + rafCallbacksPerSecond: number + tableRendersPerSecond: number + lastBatchSize: number + averageRenderMs: number + p95RenderMs: number + maxRenderMs: number + slowRenders: number + longAnimationFrames: number + worstLongAnimationFrameMs: number + heapMb: number | null + componentsCreated: number + componentsDestroyed: number + workerMessages: number + lastUpdateCount: number +} + +export const initialMetrics: FeedMetrics = { + actualEventsPerSecond: 0, + totalEvents: 0, + rafCallbacksPerSecond: 0, + tableRendersPerSecond: 0, + lastBatchSize: 0, + averageRenderMs: 0, + p95RenderMs: 0, + maxRenderMs: 0, + slowRenders: 0, + longAnimationFrames: 0, + worstLongAnimationFrameMs: 0, + heapMb: null, + componentsCreated: 0, + componentsDestroyed: 0, + workerMessages: 0, + lastUpdateCount: 0, +} + +interface PendingAck { + generation: number + sequence: number +} + +export class BenchmarkMonitor { + readonly #runtime = { + sampleStartedAt: performance.now(), + pendingRenderStartedAt: null as number | null, + pendingAck: null as PendingAck | null, + renderSamples: [] as Array, + totalEvents: 0, + eventsInSample: 0, + lastBatchSize: 0, + lastUpdateCount: 0, + workerMessages: 0, + rafCallbacksInSample: 0, + tableRendersInSample: 0, + longAnimationFrameCount: 0, + worstLongAnimationFrameMs: 0, + } + + markRenderPending(): void { + this.#runtime.pendingRenderStartedAt ??= performance.now() + } + + setPendingAck(ack: PendingAck | null): void { + this.#runtime.pendingAck = ack + } + + recordCompletedRender(postCommand: (command: MarketFeedCommand) => void) { + const runtime = this.#runtime + if (runtime.pendingRenderStartedAt !== null) { + runtime.renderSamples.push( + performance.now() - runtime.pendingRenderStartedAt, + ) + runtime.pendingRenderStartedAt = null + } + if (runtime.pendingAck) { + runtime.tableRendersInSample++ + postCommand({ type: 'ack', ...runtime.pendingAck }) + runtime.pendingAck = null + } + } + + recordBatch(eventCount: number, updateCount: number): void { + const runtime = this.#runtime + runtime.lastBatchSize = eventCount + runtime.lastUpdateCount = updateCount + runtime.eventsInSample += eventCount + runtime.totalEvents += eventCount + runtime.workerMessages++ + } + + recordAnimationFrame(): void { + this.#runtime.rafCallbacksInSample++ + } + + recordLongAnimationFrame(duration: number): void { + const runtime = this.#runtime + runtime.longAnimationFrameCount++ + runtime.worstLongAnimationFrameMs = Math.max( + runtime.worstLongAnimationFrameMs, + duration, + ) + } + + shouldPublish(now: number): boolean { + return now - this.#runtime.sampleStartedAt >= 500 + } + + publish(now: number): FeedMetrics { + const runtime = this.#runtime + const sampleDuration = now - runtime.sampleStartedAt + const sortedRenderSamples = [...runtime.renderSamples].sort( + (left, right) => left - right, + ) + const averageRenderMs = + runtime.renderSamples.length === 0 + ? 0 + : runtime.renderSamples.reduce((sum, value) => sum + value, 0) / + runtime.renderSamples.length + const p95Index = Math.max( + 0, + Math.ceil(sortedRenderSamples.length * 0.95) - 1, + ) + const metrics: FeedMetrics = { + actualEventsPerSecond: + sampleDuration === 0 + ? 0 + : (runtime.eventsInSample / sampleDuration) * 1_000, + totalEvents: runtime.totalEvents, + rafCallbacksPerSecond: + (runtime.rafCallbacksInSample / sampleDuration) * 1_000, + tableRendersPerSecond: + (runtime.tableRendersInSample / sampleDuration) * 1_000, + lastBatchSize: runtime.lastBatchSize, + averageRenderMs, + p95RenderMs: sortedRenderSamples[p95Index] ?? 0, + maxRenderMs: sortedRenderSamples.at(-1) ?? 0, + slowRenders: runtime.renderSamples.filter((value) => value > 16.7) + .length, + longAnimationFrames: runtime.longAnimationFrameCount, + worstLongAnimationFrameMs: runtime.worstLongAnimationFrameMs, + heapMb: readHeapSizeMb(), + componentsCreated: quoteCellLifecycle.created, + componentsDestroyed: quoteCellLifecycle.destroyed, + workerMessages: runtime.workerMessages, + lastUpdateCount: runtime.lastUpdateCount, + } + + runtime.sampleStartedAt = now + runtime.eventsInSample = 0 + runtime.renderSamples = [] + runtime.rafCallbacksInSample = 0 + runtime.tableRendersInSample = 0 + return metrics + } + + reset(): void { + const runtime = this.#runtime + runtime.sampleStartedAt = performance.now() + runtime.pendingRenderStartedAt = null + runtime.pendingAck = null + runtime.renderSamples = [] + runtime.totalEvents = 0 + runtime.eventsInSample = 0 + runtime.lastBatchSize = 0 + runtime.lastUpdateCount = 0 + runtime.workerMessages = 0 + runtime.rafCallbacksInSample = 0 + runtime.tableRendersInSample = 0 + runtime.longAnimationFrameCount = 0 + runtime.worstLongAnimationFrameMs = 0 + } +} + +function readHeapSizeMb(): number | null { + const memory = ( + performance as Performance & { + memory?: { usedJSHeapSize: number } + } + ).memory + return memory ? memory.usedJSHeapSize / 1_048_576 : null +} diff --git a/examples/angular/realtime-trading/src/app/beta-trading-table.ts b/examples/angular/realtime-trading/src/app/beta-trading-table.ts new file mode 100644 index 0000000000..2576824653 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/beta-trading-table.ts @@ -0,0 +1,43 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core' +import { + FlexRender, + injectTable, + stockFeatures, +} from '@tanstack/angular-table-beta' +import { createBetaTradingColumns } from './trading-columns-beta' +import type { MarketQuote } from './market-data' +import type { RendererMode } from './trading-column-types' + +@Component({ + selector: 'app-beta-trading-table', + imports: [FlexRender], + templateUrl: './table-v9.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BetaTradingTable { + readonly quotes = input.required>() + readonly rendererMode = input.required() + readonly updateQuoteAges = input.required() + readonly quoteClock = input.required() + readonly selectedSymbol = input(null) + readonly symbolSelected = output() + + readonly columns = createBetaTradingColumns({ + rendererMode: () => this.rendererMode(), + updateQuoteAges: () => this.updateQuoteAges(), + quoteClock: () => this.quoteClock(), + selectSymbol: (symbol) => this.symbolSelected.emit(symbol), + }) + + readonly table = injectTable(() => ({ + data: this.quotes(), + columns: this.columns, + features: stockFeatures, + getRowId: (row) => row.id, + })) +} diff --git a/examples/angular/realtime-trading/src/app/core/trading-benchmark.controller.ts b/examples/angular/realtime-trading/src/app/core/trading-benchmark.controller.ts new file mode 100644 index 0000000000..462d024dbf --- /dev/null +++ b/examples/angular/realtime-trading/src/app/core/trading-benchmark.controller.ts @@ -0,0 +1,290 @@ +import { + DestroyRef, + Injectable, + NgZone, + afterEveryRender, + computed, + inject, + isDevMode, + signal, +} from '@angular/core' +import { + deriveBenchmarkQuotes, + feedLoadRates, + rowWorkloadLabel, +} from '../benchmark-profiles' +import { + BenchmarkMonitor, + initialMetrics, +} from '../benchmark/benchmark-monitor' +import { applyMarketUpdates, hydrateMarketQuotes } from '../market-data' +import { TRADING_COLUMN_COUNT } from '../trading-column-types' +import type { FeedMetrics } from '../benchmark/benchmark-monitor' +import type { FeedLoadProfile, RowWorkloadMode } from '../benchmark-profiles' +import type { + MarketFeedCommand, + MarketFeedEvent, +} from '../market-feed-protocol' +import type { MarketQuote } from '../market-data' +import type { RendererMode } from '../trading-column-types' + +export type TableAdapter = 'local' | 'beta' | 'v8' + +@Injectable({ providedIn: 'root' }) +export class TradingBenchmarkController { + readonly #zone = inject(NgZone) + readonly #destroyRef = inject(DestroyRef) + readonly #worker: Worker + readonly #longAnimationFrameObserver: PerformanceObserver | null + readonly #monitor = new BenchmarkMonitor() + + readonly devMode = isDevMode() + readonly longAnimationFramesSupported = + PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') + readonly workerReady = signal(false) + readonly running = signal(true) + readonly instrumentCount = signal(250) + readonly feedLoadProfile = signal('high') + readonly targetEventsPerSecond = signal(10_000) + readonly rowWorkloadMode = signal('stable') + readonly rowWorkloadEpoch = signal(0) + readonly tableAdapter = signal('local') + readonly tableWorkerEnabled = signal(false) + readonly rendererMode = signal('stable') + readonly updateSparklines = signal(true) + readonly updateQuoteAges = signal(true) + readonly quoteClock = signal(Date.now()) + readonly quotes = signal>([]) + readonly selectedSymbol = signal(null) + readonly metrics = signal(initialMetrics) + readonly displayQuotes = computed(() => + deriveBenchmarkQuotes( + this.quotes(), + this.rowWorkloadMode(), + this.rowWorkloadEpoch(), + ), + ) + readonly selectedQuote = computed(() => { + const symbol = this.selectedSymbol() + return symbol + ? (this.displayQuotes().find((quote) => quote.symbol === symbol) ?? null) + : null + }) + + readonly mountedCells = computed( + () => this.displayQuotes().length * TRADING_COLUMN_COUNT, + ) + readonly rowWorkloadLabel = computed(() => + rowWorkloadLabel(this.rowWorkloadMode()), + ) + readonly liveComponents = computed(() => { + const metrics = this.metrics() + return metrics.componentsCreated - metrics.componentsDestroyed + }) + + #animationFrameId: number | null = null + #feedGeneration = 0 + #lastAgeClockAt = performance.now() + #lastRowWorkloadAt = performance.now() + + constructor() { + this.#worker = new Worker( + new URL('../market-feed.worker', import.meta.url), + { type: 'module' }, + ) + this.#longAnimationFrameObserver = this.longAnimationFramesSupported + ? new PerformanceObserver(this.#recordLongAnimationFrames) + : null + afterEveryRender(() => + this.#monitor.recordCompletedRender((command) => + this.#postToWorker(command), + ), + ) + this.#zone.runOutsideAngular(() => { + this.#worker.addEventListener('message', this.#handleWorkerMessage) + this.#worker.addEventListener('error', this.#handleWorkerError) + this.#longAnimationFrameObserver?.observe({ + type: 'long-animation-frame', + buffered: true, + }) + this.#animationFrameId = requestAnimationFrame(this.#feedFrame) + }) + this.#postToWorker({ + type: 'initialize', + rowCount: this.instrumentCount(), + seed: 42 + this.instrumentCount(), + running: this.running(), + targetEventsPerSecond: this.targetEventsPerSecond(), + updateSparklines: this.updateSparklines(), + }) + this.#destroyRef.onDestroy(() => { + if (this.#animationFrameId !== null) { + cancelAnimationFrame(this.#animationFrameId) + } + this.#longAnimationFrameObserver?.disconnect() + this.#worker.terminate() + }) + } + + toggleFeed(): void { + const running = !this.running() + this.running.set(running) + this.#postToWorker({ type: 'configure', running }) + } + + setRowCount(count: number): void { + this.instrumentCount.set(count) + this.#resetWorkerMarket(count) + } + + setTargetRate(rate: number): void { + this.feedLoadProfile.set('custom') + this.targetEventsPerSecond.set(rate) + this.#postToWorker({ + type: 'configure', + targetEventsPerSecond: rate, + }) + } + + setFeedLoadProfile(profile: FeedLoadProfile): void { + this.feedLoadProfile.set(profile) + if (profile === 'custom') { + return + } + + const rate = feedLoadRates[profile] + this.targetEventsPerSecond.set(rate) + this.#postToWorker({ + type: 'configure', + targetEventsPerSecond: rate, + }) + } + + setRowWorkloadMode(mode: RowWorkloadMode): void { + this.rowWorkloadMode.set(mode) + this.rowWorkloadEpoch.set(0) + this.#lastRowWorkloadAt = performance.now() + this.selectedSymbol.set(null) + } + + setTableAdapter(adapter: TableAdapter): void { + this.tableAdapter.set(adapter) + } + + setRendererMode(shouldSwap: boolean): void { + this.rendererMode.set(shouldSwap ? 'swap' : 'stable') + } + + setTableWorkerEnabled(enabled: boolean): void { + this.tableWorkerEnabled.set(enabled) + } + + setSparklineUpdates(updateSparklines: boolean): void { + this.updateSparklines.set(updateSparklines) + this.#postToWorker({ type: 'configure', updateSparklines }) + } + + setQuoteAgeUpdates(enabled: boolean): void { + this.updateQuoteAges.set(enabled) + } + + runBurst(): void { + this.#postToWorker({ type: 'burst', eventCount: 25_000 }) + } + + resetMarket(): void { + const count = this.instrumentCount() + this.selectedSymbol.set(null) + this.#monitor.reset() + this.#lastRowWorkloadAt = performance.now() + this.rowWorkloadEpoch.set(0) + this.quoteClock.set(Date.now()) + this.metrics.set(initialMetrics) + this.#resetWorkerMarket(count) + } + + readonly #feedFrame = (now: number): void => { + this.#monitor.recordAnimationFrame() + + if (this.updateQuoteAges() && now - this.#lastAgeClockAt >= 100) { + this.#monitor.markRenderPending() + this.#lastAgeClockAt = now + this.quoteClock.set(Date.now()) + } + + if ( + this.running() && + (this.rowWorkloadMode() === 'rotating-filter' || + this.rowWorkloadMode() === 'identity-churn') && + now - this.#lastRowWorkloadAt >= 1_000 + ) { + this.#monitor.markRenderPending() + this.#lastRowWorkloadAt = now + this.rowWorkloadEpoch.update((epoch) => epoch + 1) + } + + if (this.#monitor.shouldPublish(now)) { + this.metrics.set(this.#monitor.publish(now)) + } + + this.#animationFrameId = requestAnimationFrame(this.#feedFrame) + } + + readonly #recordLongAnimationFrames = ( + entries: PerformanceObserverEntryList, + ): void => { + for (const entry of entries.getEntries()) { + this.#monitor.recordLongAnimationFrame(entry.duration) + } + } + + readonly #handleWorkerMessage = ({ + data, + }: MessageEvent): void => { + if (data.type === 'ready') { + this.#feedGeneration = data.generation + this.#monitor.setPendingAck(null) + this.#monitor.markRenderPending() + this.quotes.set(hydrateMarketQuotes(data.quotes)) + this.workerReady.set(true) + return + } + + if (data.generation !== this.#feedGeneration) { + this.#postToWorker({ + type: 'ack', + generation: data.generation, + sequence: data.sequence, + }) + return + } + + this.#monitor.markRenderPending() + this.quotes.update((quotes) => applyMarketUpdates(quotes, data.updates)) + this.#monitor.recordBatch(data.eventCount, data.updates.length) + this.#monitor.setPendingAck({ + generation: data.generation, + sequence: data.sequence, + }) + } + + readonly #handleWorkerError = (error: ErrorEvent): void => { + this.workerReady.set(false) + this.running.set(false) + console.error('Market feed worker failed', error) + } + + #resetWorkerMarket(rowCount: number): void { + this.workerReady.set(false) + this.#monitor.setPendingAck(null) + this.#postToWorker({ + type: 'reset', + rowCount, + seed: 42 + rowCount, + }) + } + + #postToWorker(command: MarketFeedCommand): void { + this.#worker.postMessage(command) + } +} diff --git a/examples/angular/realtime-trading/src/app/current-trading-table.ts b/examples/angular/realtime-trading/src/app/current-trading-table.ts new file mode 100644 index 0000000000..3d6225a519 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/current-trading-table.ts @@ -0,0 +1,43 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core' +import { + FlexRender, + injectTable, + stockFeatures, +} from '@tanstack/angular-table' +import { createTradingColumns } from './trading-columns' +import type { MarketQuote } from './market-data' +import type { RendererMode } from './trading-column-types' + +@Component({ + selector: 'app-current-trading-table', + imports: [FlexRender], + templateUrl: './table-v9.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CurrentTradingTable { + readonly quotes = input.required>() + readonly rendererMode = input.required() + readonly updateQuoteAges = input.required() + readonly quoteClock = input.required() + readonly selectedSymbol = input(null) + readonly symbolSelected = output() + + readonly columns = createTradingColumns({ + rendererMode: () => this.rendererMode(), + updateQuoteAges: () => this.updateQuoteAges(), + quoteClock: () => this.quoteClock(), + selectSymbol: (symbol) => this.symbolSelected.emit(symbol), + }) + + readonly table = injectTable(() => ({ + data: this.quotes(), + columns: this.columns, + features: stockFeatures, + getRowId: (row) => row.id, + })) +} diff --git a/examples/angular/realtime-trading/src/app/market-data.ts b/examples/angular/realtime-trading/src/app/market-data.ts new file mode 100644 index 0000000000..02eefa28c3 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/market-data.ts @@ -0,0 +1,38 @@ +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './market-feed-protocol' + +export interface MarketQuote extends Omit { + history: ReadonlyArray +} + +export function hydrateMarketQuotes( + snapshots: Array, +): Array { + return snapshots.map((quote) => ({ + ...quote, + history: [...quote.history], + })) +} + +export function applyMarketUpdates( + quotes: Array, + updates: Array, +): Array { + const nextQuotes = [...quotes] + + for (const update of updates) { + const { index, history, ...values } = update + const previousQuote = quotes.at(index) + if (!previousQuote) continue + + nextQuotes[index] = { + ...previousQuote, + ...values, + history: history ?? previousQuote.history, + } + } + + return nextQuotes +} diff --git a/examples/angular/realtime-trading/src/app/market-feed-engine.ts b/examples/angular/realtime-trading/src/app/market-feed-engine.ts new file mode 100644 index 0000000000..e77d76656c --- /dev/null +++ b/examples/angular/realtime-trading/src/app/market-feed-engine.ts @@ -0,0 +1,173 @@ +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const baseInstruments = [ + ['ALP', 'Alpine Systems', 'XNAS'], + ['ARC', 'Arcadia Cloud', 'XNYS'], + ['BLU', 'Blue River Energy', 'BATS'], + ['CRN', 'Crown Robotics', 'XNAS'], + ['DYN', 'Dynasty Networks', 'XNYS'], + ['ECO', 'Ecoframe Materials', 'IEX'], + ['FLX', 'Flux Semiconductors', 'XNAS'], + ['GEO', 'Geode Analytics', 'BATS'], + ['HLX', 'Helix Biotech', 'XNYS'], + ['ION', 'Ion Mobility', 'IEX'], + ['JDE', 'Jade Financial', 'XNYS'], + ['KNT', 'Kinetic Aerospace', 'XNAS'], +] as const + +export class MarketFeedEngine { + #quotes: Array = [] + #random = createRandom(2_026) + #rowCursor = 0 + #historyTick = 0 + #eventIndex = 0 + + reset(count: number, seed: number): Array { + const random = createRandom(seed) + + this.#quotes = Array.from({ length: count }, (_, index) => { + const [baseSymbol, company, venue] = + baseInstruments[index % baseInstruments.length] + const series = Math.floor(index / baseInstruments.length) + const symbol = series === 0 ? baseSymbol : `${baseSymbol}${series}` + const open = roundPrice(20 + random() * 480) + const spread = Math.max(0.01, open * (0.0002 + random() * 0.0004)) + const history = Array.from({ length: 24 }, (_, historyIndex) => + roundPrice( + open * + (1 + Math.sin(historyIndex / 4) * 0.002 + (random() - 0.5) * 0.001), + ), + ) + const volume = Math.floor(50_000 + random() * 2_000_000) + const lastSize = Math.floor(10 + random() * 5_000) + + return { + id: `instrument-${index}`, + symbol, + company, + venue, + open, + price: open, + bid: roundPrice(open - spread / 2), + ask: roundPrice(open + spread / 2), + bidSize: Math.floor(100 + random() * 25_000), + askSize: Math.floor(100 + random() * 25_000), + lastSize, + lastMove: 0, + lastUpdatedAt: Date.now(), + volume, + turnover: roundMoney(open * volume), + history, + } + }) + + this.#random = createRandom(2_026 + seed) + this.#rowCursor = 0 + this.#historyTick = 0 + + return this.#quotes.map((quote) => ({ + ...quote, + history: [...quote.history], + })) + } + + applyEvents( + eventCount: number, + updateSparklines: boolean, + ): Array { + if (this.#quotes.length === 0 || eventCount <= 0) return [] + + const updatedAt = Date.now() + const updatedQuotes = new Map() + const stride = 97 + + this.#eventIndex = 0 + while (this.#eventIndex < eventCount) { + this.#rowCursor = (this.#rowCursor + stride) % this.#quotes.length + const quote = this.#quotes[this.#rowCursor] + const shouldUpdateHistory = + updateSparklines && this.#historyTick++ % 4 === 0 + + this.#applyTick(quote, shouldUpdateHistory, updatedAt) + + const previousUpdate = updatedQuotes.get(this.#rowCursor) + updatedQuotes.set(this.#rowCursor, { + index: this.#rowCursor, + price: quote.price, + bid: quote.bid, + ask: quote.ask, + bidSize: quote.bidSize, + askSize: quote.askSize, + lastSize: quote.lastSize, + lastMove: quote.lastMove, + lastUpdatedAt: quote.lastUpdatedAt, + volume: quote.volume, + turnover: quote.turnover, + ...(shouldUpdateHistory || previousUpdate?.history + ? { history: [...quote.history] } + : {}), + }) + this.#eventIndex++ + } + + return [...updatedQuotes.values()] + } + + #applyTick( + quote: MarketQuoteSnapshot, + updateHistory: boolean, + updatedAt: number, + ): void { + const previousPrice = quote.price + const volatility = 0.00015 + this.#random() * 0.0012 + const move = previousPrice * (this.#random() - 0.495) * volatility + const nextPrice = roundPrice(Math.max(0.1, previousPrice + move)) + const spread = Math.max( + 0.01, + nextPrice * (0.00015 + this.#random() * 0.0005), + ) + + quote.lastMove = nextPrice - previousPrice + quote.price = nextPrice + quote.bid = roundPrice(nextPrice - spread / 2) + quote.ask = roundPrice(nextPrice + spread / 2) + quote.bidSize = Math.floor(100 + this.#random() * 25_000) + quote.askSize = Math.floor(100 + this.#random() * 25_000) + quote.lastSize = Math.floor(10 + this.#random() * 5_000) + quote.lastUpdatedAt = updatedAt + quote.volume += quote.lastSize + quote.turnover = roundMoney(quote.turnover + nextPrice * quote.lastSize) + + if (updateHistory) { + quote.history = [...quote.history.slice(-23), nextPrice] + } + } +} + +function createRandom(seed: number): () => number { + const runtime = { state: seed >>> 0 } + return () => { + runtime.state += 0x6d2b79f5 + const stateValue = runtime.state + const firstMix = Math.imul( + stateValue ^ (stateValue >>> 15), + stateValue | 1, + ) + const secondMix = + firstMix + + Math.imul(firstMix ^ (firstMix >>> 7), firstMix | 61) + const value = firstMix ^ secondMix + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296 + } +} + +function roundPrice(value: number): number { + return Math.round(value * 100) / 100 +} + +function roundMoney(value: number): number { + return Math.round(value * 100) / 100 +} diff --git a/examples/angular/realtime-trading/src/app/market-feed-protocol.ts b/examples/angular/realtime-trading/src/app/market-feed-protocol.ts new file mode 100644 index 0000000000..0a8b9925f5 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/market-feed-protocol.ts @@ -0,0 +1,66 @@ +export interface MarketQuoteSnapshot { + id: string + symbol: string + company: string + venue: string + open: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + volume: number + turnover: number + history: Array +} + +export interface MarketQuoteUpdate { + index: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + volume: number + turnover: number + history?: Array +} + +export type MarketFeedCommand = + | { + type: 'initialize' + rowCount: number + seed: number + running: boolean + targetEventsPerSecond: number + updateSparklines: boolean + } + | { + type: 'configure' + running?: boolean + targetEventsPerSecond?: number + updateSparklines?: boolean + } + | { type: 'reset'; rowCount: number; seed: number } + | { type: 'burst'; eventCount: number } + | { type: 'ack'; generation: number; sequence: number } + +export type MarketFeedEvent = + | { + type: 'ready' + generation: number + quotes: Array + } + | { + type: 'batch' + generation: number + sequence: number + eventCount: number + updates: Array + } diff --git a/examples/angular/realtime-trading/src/app/market-feed.worker.ts b/examples/angular/realtime-trading/src/app/market-feed.worker.ts new file mode 100644 index 0000000000..7ce6313798 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/market-feed.worker.ts @@ -0,0 +1,133 @@ +import { MarketFeedEngine } from './market-feed-engine' +import type { + MarketFeedCommand, + MarketFeedEvent, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const engine = new MarketFeedEngine() +const pendingUpdates = new Map() + +const runtime = { + generation: 0, + sequence: 0, + inFlightSequence: null as number | null, + pendingEventCount: 0, + initialized: false, + running: true, + targetEventsPerSecond: 10_000, + updateSparklines: true, + eventBudget: 0, + lastTickAt: performance.now(), +} + +addEventListener('message', ({ data }: MessageEvent) => { + switch (data.type) { + case 'initialize': + runtime.running = data.running + runtime.targetEventsPerSecond = data.targetEventsPerSecond + runtime.updateSparklines = data.updateSparklines + reset(data.rowCount, data.seed) + break + case 'configure': + runtime.running = data.running ?? runtime.running + runtime.targetEventsPerSecond = + data.targetEventsPerSecond ?? runtime.targetEventsPerSecond + runtime.updateSparklines = + data.updateSparklines ?? runtime.updateSparklines + break + case 'reset': + reset(data.rowCount, data.seed) + break + case 'burst': + produceEvents(data.eventCount) + flush() + break + case 'ack': + if ( + data.generation === runtime.generation && + data.sequence === runtime.inFlightSequence + ) { + runtime.inFlightSequence = null + flush() + } + break + } +}) + +setInterval(() => { + const now = performance.now() + const elapsed = Math.min(100, Math.max(0, now - runtime.lastTickAt)) + runtime.lastTickAt = now + + if (runtime.initialized && runtime.running) { + runtime.eventBudget += + (runtime.targetEventsPerSecond * elapsed) / 1_000 + const eventCount = Math.floor(runtime.eventBudget) + runtime.eventBudget -= eventCount + produceEvents(eventCount) + } + + flush() +}, 16) + +function reset(rowCount: number, seed: number): void { + runtime.initialized = true + runtime.generation++ + runtime.sequence = 0 + runtime.inFlightSequence = null + runtime.pendingEventCount = 0 + pendingUpdates.clear() + runtime.eventBudget = 0 + runtime.lastTickAt = performance.now() + + post({ + type: 'ready', + generation: runtime.generation, + quotes: engine.reset(rowCount, seed), + }) +} + +function produceEvents(eventCount: number): void { + if (!runtime.initialized || eventCount <= 0) return + + runtime.pendingEventCount += eventCount + for (const update of engine.applyEvents( + eventCount, + runtime.updateSparklines, + )) { + const previousUpdate = pendingUpdates.get(update.index) + pendingUpdates.set(update.index, { + ...update, + ...(update.history || !previousUpdate?.history + ? {} + : { history: previousUpdate.history }), + }) + } +} + +function flush(): void { + if ( + runtime.inFlightSequence !== null || + runtime.pendingEventCount === 0 + ) + return + + const nextSequence = ++runtime.sequence + const message: MarketFeedEvent = { + type: 'batch', + generation: runtime.generation, + sequence: nextSequence, + eventCount: runtime.pendingEventCount, + updates: [...pendingUpdates.values()], + } + + runtime.pendingEventCount = 0 + pendingUpdates.clear() + runtime.inFlightSequence = nextSequence + post(message) +} + +function post(event: MarketFeedEvent): void { + postMessage(event) +} diff --git a/examples/angular/realtime-trading/src/app/quote-cells.ts b/examples/angular/realtime-trading/src/app/quote-cells.ts new file mode 100644 index 0000000000..7f2e3cf9e1 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/quote-cells.ts @@ -0,0 +1,258 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, +} from '@angular/core' +import type { OnDestroy } from '@angular/core' + +const compactNumber = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +export const quoteCellLifecycle = { + created: 0, + destroyed: 0, +} + +function trackCreation(): void { + quoteCellLifecycle.created++ +} + +function trackDestruction(): void { + quoteCellLifecycle.destroyed++ +} + +@Component({ + selector: 'app-price-cell', + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PriceCell implements OnDestroy { + readonly price = input.required() + readonly move = input.required() + readonly select = output() + readonly formattedPrice = computed(() => this.price().toFixed(2)) + + constructor() { + trackCreation() + } + + ngOnDestroy(): void { + trackDestruction() + } +} + +@Component({ + selector: 'app-stable-move-cell', + template: ` + + {{ formattedMove() }} + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StableMoveCell implements OnDestroy { + readonly move = input.required() + readonly formattedMove = computed(() => formatSigned(this.move())) + + constructor() { + trackCreation() + } + + ngOnDestroy(): void { + trackDestruction() + } +} + +@Component({ + selector: 'app-spread-cell', + template: ` + + {{ formattedSpread() }} + {{ basisPoints().toFixed(1) }} bp + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SpreadCell implements OnDestroy { + readonly bid = input.required() + readonly ask = input.required() + readonly spread = computed(() => Math.max(0, this.ask() - this.bid())) + readonly basisPoints = computed(() => { + const midpoint = (this.bid() + this.ask()) / 2 + return midpoint === 0 ? 0 : (this.spread() / midpoint) * 10_000 + }) + readonly formattedSpread = computed(() => this.spread().toFixed(2)) + + constructor() { + trackCreation() + } + + ngOnDestroy(): void { + trackDestruction() + } +} + +@Component({ + selector: 'app-depth-cell', + template: ` +
+ + + + {{ formattedBidSize() }} + {{ formattedAskSize() }} + +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DepthCell implements OnDestroy { + readonly bidSize = input.required() + readonly askSize = input.required() + readonly bidShare = computed(() => { + const total = this.bidSize() + this.askSize() + return total === 0 ? 50 : (this.bidSize() / total) * 100 + }) + readonly formattedBidSize = computed(() => + compactNumber.format(this.bidSize()), + ) + readonly formattedAskSize = computed(() => + compactNumber.format(this.askSize()), + ) + + constructor() { + trackCreation() + } + + ngOnDestroy(): void { + trackDestruction() + } +} + +@Component({ + selector: 'app-quote-age-cell', + template: ` + + {{ formattedAge() }} + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class QuoteAgeCell implements OnDestroy { + readonly ageMs = input.required() + readonly formattedAge = computed(() => { + const age = this.ageMs() + return age < 1_000 + ? `${Math.round(age)} ms` + : `${(age / 1_000).toFixed(1)} s` + }) + + constructor() { + trackCreation() + } + + ngOnDestroy(): void { + trackDestruction() + } +} + +@Component({ + selector: 'app-up-move-cell', + template: `โ–ฒ {{ formattedMove() }}`, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UpMoveCell implements OnDestroy { + readonly move = input.required() + readonly formattedMove = computed(() => formatSigned(this.move())) + + constructor() { + trackCreation() + } + + ngOnDestroy(): void { + trackDestruction() + } +} + +@Component({ + selector: 'app-down-move-cell', + template: `โ–ผ {{ formattedMove() }}`, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DownMoveCell implements OnDestroy { + readonly move = input.required() + readonly formattedMove = computed(() => formatSigned(this.move())) + + constructor() { + trackCreation() + } + + ngOnDestroy(): void { + trackDestruction() + } +} + +@Component({ + selector: 'app-sparkline-cell', + template: ` + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SparklineCell implements OnDestroy { + readonly values = input.required>() + readonly points = computed(() => { + const values = this.values() + const min = Math.min(...values) + const max = Math.max(...values) + const range = max - min || 1 + const denominator = Math.max(1, values.length - 1) + + return values + .map((value, index) => { + const x = (index / denominator) * 100 + const y = 22 - ((value - min) / range) * 20 + return `${x.toFixed(1)},${y.toFixed(1)}` + }) + .join(' ') + }) + + constructor() { + trackCreation() + } + + ngOnDestroy(): void { + trackDestruction() + } +} + +function formatSigned(value: number): string { + const sign = value >= 0 ? '+' : '' + return `${sign}${value.toFixed(2)}` +} diff --git a/examples/angular/realtime-trading/src/app/shell/configurator.html b/examples/angular/realtime-trading/src/app/shell/configurator.html new file mode 100644 index 0000000000..c5c1b47f4a --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/configurator.html @@ -0,0 +1,169 @@ + diff --git a/examples/angular/realtime-trading/src/app/shell/configurator.ts b/examples/angular/realtime-trading/src/app/shell/configurator.ts new file mode 100644 index 0000000000..560774567f --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/configurator.ts @@ -0,0 +1,46 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { TradingBenchmarkController } from '../core/trading-benchmark.controller' +import { Diagnostics } from './diagnostics' +import { SelectedInstrument } from './selected-instrument' +import { + formatRate, + inputChecked, + inputValue, + selectValue, +} from './shell-formatters' +import type { FeedLoadProfile, RowWorkloadMode } from '../benchmark-profiles' +import type { TableAdapter } from '../core/trading-benchmark.controller' + +@Component({ + selector: 'app-configurator', + imports: [Diagnostics, SelectedInstrument], + templateUrl: './configurator.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Configurator { + readonly controller = inject(TradingBenchmarkController) + readonly formatRate = formatRate + + readonly setRowCount = (event: Event) => + this.controller.setRowCount(Number(selectValue(event))) + readonly setTargetRate = (event: Event) => + this.controller.setTargetRate(Number(inputValue(event))) + readonly setFeedLoadProfile = (event: Event) => + this.controller.setFeedLoadProfile( + selectValue(event) as FeedLoadProfile, + ) + readonly setRowWorkloadMode = (event: Event) => + this.controller.setRowWorkloadMode( + selectValue(event) as RowWorkloadMode, + ) + readonly setTableAdapter = (event: Event) => + this.controller.setTableAdapter(selectValue(event) as TableAdapter) + readonly setRendererMode = (event: Event) => + this.controller.setRendererMode(inputChecked(event)) + readonly setTableWorkerEnabled = (event: Event) => + this.controller.setTableWorkerEnabled(inputChecked(event)) + readonly setSparklineUpdates = (event: Event) => + this.controller.setSparklineUpdates(inputChecked(event)) + readonly setQuoteAgeUpdates = (event: Event) => + this.controller.setQuoteAgeUpdates(inputChecked(event)) +} diff --git a/examples/angular/realtime-trading/src/app/shell/diagnostics.ts b/examples/angular/realtime-trading/src/app/shell/diagnostics.ts new file mode 100644 index 0000000000..d295b51ff9 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/diagnostics.ts @@ -0,0 +1,71 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { TradingBenchmarkController } from '../core/trading-benchmark.controller' +import { formatInteger } from './shell-formatters' + +@Component({ + selector: 'app-diagnostics', + template: ` +
+

DIAGNOSTICS

+
+
+
Mounted cells
+
{{ formatInteger(controller.mountedCells()) }}
+
+
+
Live components
+
{{ formatInteger(controller.liveComponents()) }}
+
+
+
Created / destroyed
+
+ {{ formatInteger(controller.metrics().componentsCreated) }} / + {{ formatInteger(controller.metrics().componentsDestroyed) }} +
+
+
+
Worker messages
+
+ {{ formatInteger(controller.metrics().workerMessages) }} +
+
+
+
Last batch events / rows
+
+ {{ formatInteger(controller.metrics().lastBatchSize) }} / + {{ formatInteger(controller.metrics().lastUpdateCount) }} +
+
+
+
Renders > 16.7 ms
+
{{ controller.metrics().slowRenders }}
+
+
+
Long animation frames
+
+ {{ + controller.longAnimationFramesSupported + ? formatInteger(controller.metrics().longAnimationFrames) + : 'Unsupported' + }} +
+
+
+
JS heap
+
+ {{ + controller.metrics().heapMb === null + ? 'N/A' + : controller.metrics().heapMb!.toFixed(1) + ' MB' + }} +
+
+
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Diagnostics { + readonly controller = inject(TradingBenchmarkController) + readonly formatInteger = formatInteger +} diff --git a/examples/angular/realtime-trading/src/app/shell/market-statusbar.ts b/examples/angular/realtime-trading/src/app/shell/market-statusbar.ts new file mode 100644 index 0000000000..3006101ab0 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/market-statusbar.ts @@ -0,0 +1,38 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { TradingBenchmarkController } from '../core/trading-benchmark.controller' +import { formatInteger } from './shell-formatters' + +@Component({ + selector: 'app-market-statusbar', + template: ` +
+ + BATCH EVENTS + + {{ formatInteger(controller.metrics().lastBatchSize) }} + + + + ROW UPDATES + + {{ formatInteger(controller.metrics().lastUpdateCount) }} + + + + HOSTS + {{ formatInteger(controller.mountedCells()) }} + + + COMPONENTS + {{ formatInteger(controller.liveComponents()) }} + + + WORKER / ACKNOWLEDGED / IMMUTABLE +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MarketStatusbar { + readonly controller = inject(TradingBenchmarkController) + readonly formatInteger = formatInteger +} diff --git a/examples/angular/realtime-trading/src/app/shell/market-toolbar.ts b/examples/angular/realtime-trading/src/app/shell/market-toolbar.ts new file mode 100644 index 0000000000..a42c0e55c7 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/market-toolbar.ts @@ -0,0 +1,59 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { TradingBenchmarkController } from '../core/trading-benchmark.controller' +import { formatInteger } from './shell-formatters' + +@Component({ + selector: 'app-market-toolbar', + template: ` +
+
+ WATCHLIST + ALL INSTRUMENTS +
+
+ + {{ formatInteger(controller.displayQuotes().length) }} / + {{ formatInteger(controller.quotes().length) }} SYMBOLS + + + {{ + controller.tableAdapter() === 'local' + ? 'LOCAL OPTIMIZED' + : controller.tableAdapter() === 'beta' + ? 'BETA.80' + : 'V8.21.4' + }} + + WORKER STREAM + + {{ + controller.tableAdapter() === 'local' && + controller.tableWorkerEnabled() + ? 'ROW MODEL WORKER ON' + : 'ROW MODEL MAIN THREAD' + }} + + IMMUTABLE ROWS + {{ controller.rowWorkloadLabel() }} + + {{ + controller.rendererMode() === 'stable' + ? 'STABLE CELLS' + : 'A/B CELL SWAP' + }} + + + {{ controller.updateSparklines() ? 'CHARTS ON' : 'CHARTS OFF' }} + + + {{ controller.updateQuoteAges() ? 'AGE CLOCK ON' : 'AGE CLOCK OFF' }} + +
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MarketToolbar { + readonly controller = inject(TradingBenchmarkController) + readonly formatInteger = formatInteger +} diff --git a/examples/angular/realtime-trading/src/app/shell/metrics-strip.ts b/examples/angular/realtime-trading/src/app/shell/metrics-strip.ts new file mode 100644 index 0000000000..145d7121f7 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/metrics-strip.ts @@ -0,0 +1,74 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { TradingBenchmarkController } from '../core/trading-benchmark.controller' +import { formatInteger, formatMs, formatRate } from './shell-formatters' + +@Component({ + selector: 'app-metrics-strip', + template: ` +
+
+ THROUGHPUT + + {{ formatRate(controller.metrics().actualEventsPerSecond) }} + + events/s +
+
+ RAF RATE + + {{ controller.metrics().rafCallbacksPerSecond.toFixed(1) }} + + callbacks/s +
+
+ TABLE RENDERS + + {{ controller.metrics().tableRendersPerSecond.toFixed(1) }} + + worker batches/s +
+
+ AVG RENDER + {{ formatMs(controller.metrics().averageRenderMs) }} + mutation โ†’ render +
+
+ P95 RENDER + {{ formatMs(controller.metrics().p95RenderMs) }} + max {{ formatMs(controller.metrics().maxRenderMs) }} +
+
+ LONG FRAMES + @if (controller.longAnimationFramesSupported) { + + {{ controller.metrics().longAnimationFrames }} + + + worst + {{ formatMs(controller.metrics().worstLongAnimationFrameMs) }} + + } @else { + N/A + unsupported + } +
+
+ TOTAL EVENTS + + {{ formatInteger(controller.metrics().totalEvents) }} + + since reset +
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MetricsStrip { + readonly controller = inject(TradingBenchmarkController) + readonly formatInteger = formatInteger + readonly formatMs = formatMs + readonly formatRate = formatRate +} diff --git a/examples/angular/realtime-trading/src/app/shell/selected-instrument.ts b/examples/angular/realtime-trading/src/app/shell/selected-instrument.ts new file mode 100644 index 0000000000..541d30fe2b --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/selected-instrument.ts @@ -0,0 +1,36 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { TradingBenchmarkController } from '../core/trading-benchmark.controller' + +@Component({ + selector: 'app-selected-instrument', + template: ` +
+

SELECTED INSTRUMENT

+ @if (controller.selectedQuote(); as quote) { +
+
+ {{ quote.symbol }} + {{ quote.company }} +
+ {{ quote.venue }} +
+
+
+
Last
+
{{ quote.price.toFixed(2) }}
+
+
+
Bid / ask
+
{{ quote.bid.toFixed(2) }} / {{ quote.ask.toFixed(2) }}
+
+
+ } @else { +

Click a value in the Last column to inspect its output.

+ } +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SelectedInstrument { + readonly controller = inject(TradingBenchmarkController) +} diff --git a/examples/angular/realtime-trading/src/app/shell/shell-formatters.ts b/examples/angular/realtime-trading/src/app/shell/shell-formatters.ts new file mode 100644 index 0000000000..7686f5fbaa --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/shell-formatters.ts @@ -0,0 +1,20 @@ +const integerFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 0, +}) +const rateFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +export const formatInteger = (value: number): string => + integerFormatter.format(value) +export const formatRate = (value: number): string => + rateFormatter.format(value) +export const formatMs = (value: number): string => `${value.toFixed(2)} ms` + +export const selectValue = (event: Event): string => + (event.target as HTMLSelectElement).value +export const inputValue = (event: Event): string => + (event.target as HTMLInputElement).value +export const inputChecked = (event: Event): boolean => + (event.target as HTMLInputElement).checked diff --git a/examples/angular/realtime-trading/src/app/shell/shell-header.ts b/examples/angular/realtime-trading/src/app/shell/shell-header.ts new file mode 100644 index 0000000000..04e763f0f3 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/shell-header.ts @@ -0,0 +1,36 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { TradingBenchmarkController } from '../core/trading-benchmark.controller' + +@Component({ + selector: 'app-shell-header', + template: ` +
+
+ TT + MARKET MONITOR + SIMULATED +
+
+ ANGULAR / FLEX RENDER + + + {{ + !controller.workerReady() + ? 'FEED CONNECTING' + : controller.running() + ? 'FEED LIVE' + : 'FEED PAUSED' + }} + +
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ShellHeader { + readonly controller = inject(TradingBenchmarkController) +} diff --git a/examples/angular/realtime-trading/src/app/shell/trading-shell.html b/examples/angular/realtime-trading/src/app/shell/trading-shell.html new file mode 100644 index 0000000000..02abfd1289 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/trading-shell.html @@ -0,0 +1,20 @@ +
+ + + @if (devMode) { + + } + +
+
+ + + + +
+ + +
+
diff --git a/examples/angular/realtime-trading/src/app/shell/trading-shell.ts b/examples/angular/realtime-trading/src/app/shell/trading-shell.ts new file mode 100644 index 0000000000..bff3c49918 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/trading-shell.ts @@ -0,0 +1,23 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { TradingBenchmarkController } from '../core/trading-benchmark.controller' +import { Configurator } from './configurator' +import { MarketStatusbar } from './market-statusbar' +import { MarketToolbar } from './market-toolbar' +import { MetricsStrip } from './metrics-strip' +import { ShellHeader } from './shell-header' + +@Component({ + selector: 'app-trading-shell', + imports: [ + Configurator, + MarketStatusbar, + MarketToolbar, + MetricsStrip, + ShellHeader, + ], + templateUrl: './trading-shell.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TradingShell { + readonly devMode = inject(TradingBenchmarkController).devMode +} diff --git a/examples/angular/realtime-trading/src/app/table-row-model.worker.ts b/examples/angular/realtime-trading/src/app/table-row-model.worker.ts new file mode 100644 index 0000000000..ad7a0357ad --- /dev/null +++ b/examples/angular/realtime-trading/src/app/table-row-model.worker.ts @@ -0,0 +1,44 @@ +import { + createFilteredRowModel, + stockFeatures, + tableFeatures, +} from '@tanstack/angular-table' +import { + initTableWorker, + workerRowModelsFeature, +} from '@tanstack/angular-table/experimental-worker-plugin' +import type { ColumnDef } from '@tanstack/angular-table' +import type { MarketQuote } from './market-data' + +const workerFeatures = tableFeatures({ + ...stockFeatures, + workerRowModelsFeature, + filteredRowModel: createFilteredRowModel(), +}) + +// The worker only computes row models, so it needs portable accessors but none +// of the Angular render components used by the visible table. +const workerColumns: Array< + ColumnDef +> = [ + { accessorKey: 'symbol' }, + { accessorKey: 'venue' }, + { accessorKey: 'bid' }, + { accessorKey: 'ask' }, + { accessorKey: 'price' }, + { accessorKey: 'lastMove' }, + { accessorKey: 'bidSize' }, + { accessorKey: 'askSize' }, + { accessorKey: 'lastSize' }, + { accessorKey: 'lastUpdatedAt' }, + { accessorKey: 'open' }, + { accessorKey: 'volume' }, + { accessorKey: 'turnover' }, + { accessorKey: 'history' }, +] + +initTableWorker({ + features: workerFeatures, + columns: workerColumns, + getRowId: (row) => row.id, +}) diff --git a/examples/angular/realtime-trading/src/app/table-v8.html b/examples/angular/realtime-trading/src/app/table-v8.html new file mode 100644 index 0000000000..6d8153d3c8 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/table-v8.html @@ -0,0 +1,48 @@ +
+ + + @for (headerGroup of table.getHeaderGroups(); track headerGroup.id) { + + @for (header of headerGroup.headers; track header.id) { + + } + + } + + + @for (row of table.getRowModel().rows; track row.id) { + + @for (cell of row.getVisibleCells(); track cell.id) { + + } + + } + +
+ @if (!header.isPlaceholder) { + + {{ headerCell }} + + } +
+ + {{ renderCell }} + +
+
diff --git a/examples/angular/realtime-trading/src/app/table-v9.html b/examples/angular/realtime-trading/src/app/table-v9.html new file mode 100644 index 0000000000..9563edc157 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/table-v9.html @@ -0,0 +1,40 @@ +
+ + + @for (headerGroup of table.getHeaderGroups(); track headerGroup.id) { + + @for (header of headerGroup.headers; track header.id) { + + } + + } + + + @for (row of table.getRowModel().rows; track row.id) { + + @for (cell of row.getVisibleCells(); track cell.id) { + + } + + } + +
+ @if (!header.isPlaceholder) { + + {{ headerCell }} + + } +
+ + {{ renderCell }} + +
+
diff --git a/examples/angular/realtime-trading/src/app/trading-column-types.ts b/examples/angular/realtime-trading/src/app/trading-column-types.ts new file mode 100644 index 0000000000..bfac6f3870 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/trading-column-types.ts @@ -0,0 +1,10 @@ +export type RendererMode = 'stable' | 'swap' + +export interface TradingColumnState { + quoteClock: () => number + rendererMode: () => RendererMode + selectSymbol: (symbol: string) => void + updateQuoteAges: () => boolean +} + +export const TRADING_COLUMN_COUNT = 14 diff --git a/examples/angular/realtime-trading/src/app/trading-columns-beta.ts b/examples/angular/realtime-trading/src/app/trading-columns-beta.ts new file mode 100644 index 0000000000..03c6175e35 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/trading-columns-beta.ts @@ -0,0 +1,168 @@ +import { flexRenderComponent } from '@tanstack/angular-table-beta' +import { + DepthCell, + DownMoveCell, + PriceCell, + QuoteAgeCell, + SparklineCell, + SpreadCell, + StableMoveCell, + UpMoveCell, +} from './quote-cells' +import type { + ColumnDef, + TableFeatures, +} from '@tanstack/angular-table-beta' +import type { MarketQuote } from './market-data' +import type { TradingColumnState } from './trading-column-types' + +const compactFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +const currencyFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + style: 'currency', + currency: 'USD', +}) + +export function createBetaTradingColumns( + state: TradingColumnState, +): Array> { + return [ + { + id: 'symbol', + header: 'Ticker', + size: 90, + cell: ({ row }) => row.original.symbol, + }, + { + id: 'venue', + header: 'Venue', + size: 70, + cell: ({ row }) => row.original.venue, + }, + { + id: 'bid', + header: 'Bid', + size: 90, + cell: ({ row }) => row.original.bid.toFixed(2), + }, + { + id: 'ask', + header: 'Ask', + size: 90, + cell: ({ row }) => row.original.ask.toFixed(2), + }, + { + id: 'spread', + header: 'Spread', + size: 95, + cell: ({ row }) => + flexRenderComponent(SpreadCell, { + inputs: { + bid: row.original.bid, + ask: row.original.ask, + }, + }), + }, + { + id: 'price', + header: 'Last', + size: 100, + cell: ({ row }) => + flexRenderComponent(PriceCell, { + inputs: { + price: row.original.price, + move: row.original.lastMove, + }, + outputs: { + select: () => state.selectSymbol(row.original.symbol), + }, + }), + }, + { + id: 'lastMove', + header: 'Last Move', + size: 105, + cell: ({ row }) => { + const move = row.original.lastMove + if (state.rendererMode() === 'stable') { + return flexRenderComponent(StableMoveCell, { + inputs: { move }, + }) + } + return flexRenderComponent(move >= 0 ? UpMoveCell : DownMoveCell, { + inputs: { move }, + }) + }, + }, + { + id: 'lastSize', + header: 'Last Qty', + size: 90, + cell: ({ row }) => compactFormatter.format(row.original.lastSize), + }, + { + id: 'depth', + header: 'Bid / Ask Qty', + size: 145, + cell: ({ row }) => + flexRenderComponent(DepthCell, { + inputs: { + bidSize: row.original.bidSize, + askSize: row.original.askSize, + }, + }), + }, + { + id: 'age', + header: 'Quote Age', + size: 85, + cell: ({ row }) => + flexRenderComponent(QuoteAgeCell, { + inputs: { + ageMs: state.updateQuoteAges() + ? Math.max( + 0, + state.quoteClock() - row.original.lastUpdatedAt, + ) + : 0, + }, + }), + }, + { + id: 'change', + header: 'Day %', + size: 90, + cell: ({ row }) => { + const change = (row.original.price / row.original.open - 1) * 100 + const sign = change >= 0 ? '+' : '' + return `${sign}${change.toFixed(2)}%` + }, + }, + { + id: 'volume', + header: 'Total Qty', + size: 100, + cell: ({ row }) => compactFormatter.format(row.original.volume), + }, + { + id: 'turnover', + header: 'Traded Value', + size: 115, + cell: ({ row }) => currencyFormatter.format(row.original.turnover), + }, + { + id: 'history', + header: 'Intraday', + size: 150, + cell: ({ row }) => + flexRenderComponent(SparklineCell, { + inputs: { values: row.original.history }, + }), + }, + ] +} diff --git a/examples/angular/realtime-trading/src/app/trading-columns-v8.ts b/examples/angular/realtime-trading/src/app/trading-columns-v8.ts new file mode 100644 index 0000000000..1f13889d18 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/trading-columns-v8.ts @@ -0,0 +1,165 @@ +import { flexRenderComponent } from '@tanstack/angular-table-v8' +import { + DepthCell, + DownMoveCell, + PriceCell, + QuoteAgeCell, + SparklineCell, + SpreadCell, + StableMoveCell, + UpMoveCell, +} from './quote-cells' +import type { ColumnDef } from '@tanstack/angular-table-v8' +import type { MarketQuote } from './market-data' +import type { TradingColumnState } from './trading-column-types' + +const compactFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +const currencyFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + style: 'currency', + currency: 'USD', +}) + +export function createV8TradingColumns( + state: TradingColumnState, +): Array> { + return [ + { + id: 'symbol', + header: 'Ticker', + size: 90, + cell: ({ row }) => row.original.symbol, + }, + { + id: 'venue', + header: 'Venue', + size: 70, + cell: ({ row }) => row.original.venue, + }, + { + id: 'bid', + header: 'Bid', + size: 90, + cell: ({ row }) => row.original.bid.toFixed(2), + }, + { + id: 'ask', + header: 'Ask', + size: 90, + cell: ({ row }) => row.original.ask.toFixed(2), + }, + { + id: 'spread', + header: 'Spread', + size: 95, + cell: ({ row }) => + flexRenderComponent(SpreadCell, { + inputs: { + bid: row.original.bid, + ask: row.original.ask, + }, + }), + }, + { + id: 'price', + header: 'Last', + size: 100, + cell: ({ row }) => + flexRenderComponent(PriceCell, { + inputs: { + price: row.original.price, + move: row.original.lastMove, + }, + outputs: { + select: () => state.selectSymbol(row.original.symbol), + }, + }), + }, + { + id: 'lastMove', + header: 'Last Move', + size: 105, + cell: ({ row }) => { + const move = row.original.lastMove + if (state.rendererMode() === 'stable') { + return flexRenderComponent(StableMoveCell, { + inputs: { move }, + }) + } + return flexRenderComponent(move >= 0 ? UpMoveCell : DownMoveCell, { + inputs: { move }, + }) + }, + }, + { + id: 'lastSize', + header: 'Last Qty', + size: 90, + cell: ({ row }) => compactFormatter.format(row.original.lastSize), + }, + { + id: 'depth', + header: 'Bid / Ask Qty', + size: 145, + cell: ({ row }) => + flexRenderComponent(DepthCell, { + inputs: { + bidSize: row.original.bidSize, + askSize: row.original.askSize, + }, + }), + }, + { + id: 'age', + header: 'Quote Age', + size: 85, + cell: ({ row }) => + flexRenderComponent(QuoteAgeCell, { + inputs: { + ageMs: state.updateQuoteAges() + ? Math.max( + 0, + state.quoteClock() - row.original.lastUpdatedAt, + ) + : 0, + }, + }), + }, + { + id: 'change', + header: 'Day %', + size: 90, + cell: ({ row }) => { + const change = (row.original.price / row.original.open - 1) * 100 + const sign = change >= 0 ? '+' : '' + return `${sign}${change.toFixed(2)}%` + }, + }, + { + id: 'volume', + header: 'Total Qty', + size: 100, + cell: ({ row }) => compactFormatter.format(row.original.volume), + }, + { + id: 'turnover', + header: 'Traded Value', + size: 115, + cell: ({ row }) => currencyFormatter.format(row.original.turnover), + }, + { + id: 'history', + header: 'Intraday', + size: 150, + cell: ({ row }) => + flexRenderComponent(SparklineCell, { + inputs: { values: row.original.history }, + }), + }, + ] +} diff --git a/examples/angular/realtime-trading/src/app/trading-columns.ts b/examples/angular/realtime-trading/src/app/trading-columns.ts new file mode 100644 index 0000000000..5fdc40efcb --- /dev/null +++ b/examples/angular/realtime-trading/src/app/trading-columns.ts @@ -0,0 +1,165 @@ +import { flexRenderComponent } from '@tanstack/angular-table' +import { + DepthCell, + DownMoveCell, + PriceCell, + QuoteAgeCell, + SparklineCell, + SpreadCell, + StableMoveCell, + UpMoveCell, +} from './quote-cells' +import type { ColumnDef, TableFeatures } from '@tanstack/angular-table' +import type { MarketQuote } from './market-data' +import type { TradingColumnState } from './trading-column-types' + +const compactFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +const currencyFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + style: 'currency', + currency: 'USD', +}) + +export function createTradingColumns( + state: TradingColumnState, +): Array> { + return [ + { + id: 'symbol', + header: 'Ticker', + size: 90, + cell: ({ row }) => row.original.symbol, + }, + { + id: 'venue', + header: 'Venue', + size: 70, + cell: ({ row }) => row.original.venue, + }, + { + id: 'bid', + header: 'Bid', + size: 90, + cell: ({ row }) => row.original.bid.toFixed(2), + }, + { + id: 'ask', + header: 'Ask', + size: 90, + cell: ({ row }) => row.original.ask.toFixed(2), + }, + { + id: 'spread', + header: 'Spread', + size: 95, + cell: ({ row }) => + flexRenderComponent(SpreadCell, { + inputs: { + bid: row.original.bid, + ask: row.original.ask, + }, + }), + }, + { + id: 'price', + header: 'Last', + size: 100, + cell: ({ row }) => + flexRenderComponent(PriceCell, { + inputs: { + price: row.original.price, + move: row.original.lastMove, + }, + outputs: { + select: () => state.selectSymbol(row.original.symbol), + }, + }), + }, + { + id: 'lastMove', + header: 'Last Move', + size: 105, + cell: ({ row }) => { + const move = row.original.lastMove + if (state.rendererMode() === 'stable') { + return flexRenderComponent(StableMoveCell, { + inputs: { move }, + }) + } + return flexRenderComponent(move >= 0 ? UpMoveCell : DownMoveCell, { + inputs: { move }, + }) + }, + }, + { + id: 'lastSize', + header: 'Last Qty', + size: 90, + cell: ({ row }) => compactFormatter.format(row.original.lastSize), + }, + { + id: 'depth', + header: 'Bid / Ask Qty', + size: 145, + cell: ({ row }) => + flexRenderComponent(DepthCell, { + inputs: { + bidSize: row.original.bidSize, + askSize: row.original.askSize, + }, + }), + }, + { + id: 'age', + header: 'Quote Age', + size: 85, + cell: ({ row }) => + flexRenderComponent(QuoteAgeCell, { + inputs: { + ageMs: state.updateQuoteAges() + ? Math.max( + 0, + state.quoteClock() - row.original.lastUpdatedAt, + ) + : 0, + }, + }), + }, + { + id: 'change', + header: 'Day %', + size: 90, + cell: ({ row }) => { + const change = (row.original.price / row.original.open - 1) * 100 + const sign = change >= 0 ? '+' : '' + return `${sign}${change.toFixed(2)}%` + }, + }, + { + id: 'volume', + header: 'Total Qty', + size: 100, + cell: ({ row }) => compactFormatter.format(row.original.volume), + }, + { + id: 'turnover', + header: 'Traded Value', + size: 115, + cell: ({ row }) => currencyFormatter.format(row.original.turnover), + }, + { + id: 'history', + header: 'Intraday', + size: 150, + cell: ({ row }) => + flexRenderComponent(SparklineCell, { + inputs: { values: row.original.history }, + }), + }, + ] +} diff --git a/examples/angular/realtime-trading/src/app/v8-trading-table.ts b/examples/angular/realtime-trading/src/app/v8-trading-table.ts new file mode 100644 index 0000000000..87e254af66 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/v8-trading-table.ts @@ -0,0 +1,43 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core' +import { + FlexRenderDirective, + createAngularTable, + getCoreRowModel, +} from '@tanstack/angular-table-v8' +import { createV8TradingColumns } from './trading-columns-v8' +import type { MarketQuote } from './market-data' +import type { RendererMode } from './trading-column-types' + +@Component({ + selector: 'app-v8-trading-table', + imports: [FlexRenderDirective], + templateUrl: './table-v8.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class V8TradingTable { + readonly quotes = input.required>() + readonly rendererMode = input.required() + readonly updateQuoteAges = input.required() + readonly quoteClock = input.required() + readonly selectedSymbol = input(null) + readonly symbolSelected = output() + + readonly columns = createV8TradingColumns({ + rendererMode: () => this.rendererMode(), + updateQuoteAges: () => this.updateQuoteAges(), + quoteClock: () => this.quoteClock(), + selectSymbol: (symbol) => this.symbolSelected.emit(symbol), + }) + + readonly table = createAngularTable(() => ({ + data: this.quotes(), + columns: this.columns, + getCoreRowModel: getCoreRowModel(), + getRowId: (row) => row.id, + })) +} diff --git a/examples/angular/realtime-trading/src/app/worker-trading-table.ts b/examples/angular/realtime-trading/src/app/worker-trading-table.ts new file mode 100644 index 0000000000..9fad8b38da --- /dev/null +++ b/examples/angular/realtime-trading/src/app/worker-trading-table.ts @@ -0,0 +1,79 @@ +import { + ChangeDetectionStrategy, + Component, + HostBinding, + input, + output, +} from '@angular/core' +import { + FlexRender, + injectTable, + stockFeatures, + tableFeatures, +} from '@tanstack/angular-table' +import { + createTableWorker, + createWorkerRowModel, + workerRowModelsFeature, +} from '@tanstack/angular-table/experimental-worker-plugin' +import { createTradingColumns } from './trading-columns' +import type { OnDestroy } from '@angular/core' +import type { MarketQuote } from './market-data' +import type { RendererMode } from './trading-column-types' + +const tableWorker = createTableWorker({ + createWorker: () => + new Worker(new URL('./table-row-model.worker', import.meta.url), { + type: 'module', + }), +}) + +const workerFeatures = tableFeatures({ + ...stockFeatures, + workerRowModelsFeature, + filteredRowModel: createWorkerRowModel(tableWorker, 'filtered'), +}) + +@Component({ + selector: 'app-worker-trading-table', + imports: [FlexRender], + templateUrl: './table-v9.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WorkerTradingTable implements OnDestroy { + readonly quotes = input.required>() + readonly rendererMode = input.required() + readonly updateQuoteAges = input.required() + readonly quoteClock = input.required() + readonly selectedSymbol = input(null) + readonly symbolSelected = output() + + readonly columns = createTradingColumns({ + rendererMode: () => this.rendererMode(), + updateQuoteAges: () => this.updateQuoteAges(), + quoteClock: () => this.quoteClock(), + selectSymbol: (symbol) => this.symbolSelected.emit(symbol), + }) + + readonly table = injectTable(() => ({ + data: this.quotes(), + columns: this.columns, + features: workerFeatures, + getRowId: (row) => row.id, + })) + + @HostBinding('attr.data-table-worker-pending') + get workerPending(): string { + return String(this.table.store.get().workerRowModels.isPending) + } + + @HostBinding('attr.data-table-worker-compute-ms') + get workerComputeMs(): string | null { + const computeMs = this.table.store.get().workerRowModels.lastComputeMs + return computeMs === undefined ? null : computeMs.toFixed(3) + } + + ngOnDestroy(): void { + tableWorker.terminate() + } +} diff --git a/examples/angular/realtime-trading/src/index.html b/examples/angular/realtime-trading/src/index.html new file mode 100644 index 0000000000..f1b1a69962 --- /dev/null +++ b/examples/angular/realtime-trading/src/index.html @@ -0,0 +1,16 @@ + + + + + Angular Real-time Trading flexRender Lab + + + + + + + + diff --git a/examples/angular/realtime-trading/src/main.ts b/examples/angular/realtime-trading/src/main.ts new file mode 100644 index 0000000000..c9b5d07d64 --- /dev/null +++ b/examples/angular/realtime-trading/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser' +import { App } from './app/app' +import { appConfig } from './app/app.config' + +bootstrapApplication(App, appConfig).catch((error) => console.error(error)) diff --git a/examples/angular/realtime-trading/src/styles.css b/examples/angular/realtime-trading/src/styles.css new file mode 100644 index 0000000000..d728afdc74 --- /dev/null +++ b/examples/angular/realtime-trading/src/styles.css @@ -0,0 +1,724 @@ +:root { + color-scheme: dark; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-synthesis: none; + --background: #090c11; + --panel: #10151c; + --panel-raised: #151b24; + --panel-hover: #19212b; + --border: #28313d; + --border-soft: #1d2530; + --text: #d7dde5; + --text-strong: #f1f4f8; + --muted: #7f8a98; + --blue: #4f8cff; + --blue-soft: #8cb5ff; + --green: #42c98a; + --red: #ef6a78; + --amber: #e8b95f; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-width: 320px; + min-height: 100%; + margin: 0; + background: var(--background); +} + +body { + color: var(--text); +} + +button, +select, +input { + font: inherit; +} + +button, +select { + color: var(--text); + background: var(--panel-raised); + border: 1px solid #394452; + border-radius: 2px; +} + +button { + padding: 0.5rem 0.65rem; + cursor: pointer; + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.025em; +} + +button:hover { + background: #1c2530; + border-color: #536171; +} + +button:focus-visible, +select:focus-visible, +input:focus-visible { + outline: 1px solid var(--blue); + outline-offset: 1px; +} + +select { + width: 100%; + padding: 0.45rem 0.55rem; + font-size: 0.75rem; +} + +.trading-terminal { + display: flex; + flex-direction: column; + width: 100%; + height: 100vh; + min-height: 640px; + overflow: hidden; + background: var(--background); +} + +.app-bar { + display: flex; + flex: 0 0 42px; + align-items: center; + justify-content: space-between; + padding: 0 0.75rem; + background: #0d1117; + border-bottom: 1px solid var(--border); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.68rem; + letter-spacing: 0.045em; +} + +.brand, +.session-info, +.feed-status { + display: flex; + align-items: center; +} + +.brand { + gap: 0.65rem; + color: var(--text-strong); +} + +.brand-mark { + display: grid; + width: 24px; + height: 24px; + place-items: center; + color: #fff; + background: var(--blue); + font-size: 0.62rem; + font-weight: 800; +} + +.environment { + padding: 0.16rem 0.3rem; + color: var(--amber); + background: rgb(232 185 95 / 8%); + border: 1px solid rgb(232 185 95 / 40%); + font-size: 0.58rem; +} + +.session-info { + gap: 1rem; + color: var(--muted); +} + +.feed-status { + gap: 0.4rem; + color: #9aa4b1; +} + +.feed-status.is-running { + color: var(--green); +} + +.status-dot { + width: 6px; + height: 6px; + background: #66717d; + border-radius: 50%; +} + +.feed-status.is-running .status-dot { + background: var(--green); +} + +.development-warning { + flex: 0 0 auto; + padding: 0.3rem 0.75rem; + color: #e9c67e; + background: #211b11; + border-bottom: 1px solid #4a3d25; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.62rem; + letter-spacing: 0.04em; +} + +.workspace { + display: grid; + flex: 1; + grid-template-columns: minmax(0, 1fr) 288px; + min-height: 0; +} + +.market-panel { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + background: var(--panel); + border-right: 1px solid var(--border); +} + +.market-toolbar { + display: flex; + flex: 0 0 42px; + align-items: center; + justify-content: space-between; + padding: 0 0.75rem; + background: #11171f; + border-bottom: 1px solid var(--border); +} + +.watchlist-name { + display: flex; + gap: 0.6rem; + align-items: baseline; + font-size: 0.67rem; +} + +.watchlist-name span { + color: var(--muted); +} + +.watchlist-name strong { + color: var(--text-strong); + font-size: 0.72rem; + letter-spacing: 0.035em; +} + +.market-context { + display: flex; + gap: 1rem; + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.6rem; +} + +.metrics-strip { + display: grid; + flex: 0 0 64px; + grid-template-columns: repeat(7, minmax(0, 1fr)); + background: var(--border); + border-bottom: 1px solid var(--border); + gap: 1px; +} + +.metrics-strip article { + min-width: 0; + padding: 0.55rem 0.65rem; + background: #0e131a; +} + +.metrics-strip span, +.metrics-strip small { + display: block; + overflow: hidden; + color: var(--muted); + font-size: 0.56rem; + letter-spacing: 0.045em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.metrics-strip strong { + display: block; + margin: 0.22rem 0 0.12rem; + overflow: hidden; + color: var(--text-strong); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.88rem; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; +} + +.metrics-strip strong.metric-alert { + color: var(--amber); +} + +.table-scroll { + flex: 1; + min-height: 0; + overflow: auto; + background: #0d1218; +} + +app-current-trading-table, +app-worker-trading-table, +app-beta-trading-table, +app-v8-trading-table { + display: flex; + flex: 1; + min-width: 0; + min-height: 0; +} + +table { + min-width: 100%; + border-spacing: 0; + table-layout: fixed; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; + font-variant-numeric: tabular-nums; +} + +thead { + position: sticky; + z-index: 2; + top: 0; +} + +th { + height: 28px; + padding: 0 0.6rem; + color: #8e99a6; + background: #171e27; + border-right: 1px solid #222b36; + border-bottom: 1px solid #394452; + font-size: 0.58rem; + font-weight: 700; + letter-spacing: 0.055em; + text-align: left; + text-transform: uppercase; +} + +td { + height: 27px; + padding: 0 0.6rem; + overflow: hidden; + color: #cbd2db; + border-right: 1px solid #1a222c; + border-bottom: 1px solid #1b232d; + text-overflow: ellipsis; + white-space: nowrap; +} + +th:last-child, +td:last-child { + border-right: 0; +} + +.numeric-cell { + text-align: right; +} + +tbody tr:nth-child(even) { + background: rgb(255 255 255 / 1.3%); +} + +tbody tr:hover { + background: #17202a; +} + +tbody tr.is-selected { + background: rgb(79 140 255 / 13%); + box-shadow: inset 2px 0 0 var(--blue); +} + +.price-button { + min-width: 4.5rem; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-variant-numeric: tabular-nums; + font-weight: 600; + text-align: right; +} + +.price-button:hover { + background: transparent; + text-decoration: underline; + text-underline-offset: 2px; +} + +.move-cell { + display: inline-block; + min-width: 4.8rem; +} + +.spread-cell { + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: 0.35rem; + color: #cbd2db; +} + +.spread-cell small { + min-width: 2.8rem; + color: var(--muted); + font-size: 0.55rem; +} + +.spread-cell.spread-wide, +.spread-cell.spread-wide small { + color: var(--amber); +} + +app-depth-cell { + display: block; + width: 100%; +} + +.depth-cell { + position: relative; + display: flex; + height: 17px; + overflow: hidden; + background: #151b23; +} + +.depth-bid, +.depth-ask { + height: 100%; + opacity: 0.28; +} + +.depth-bid { + background: var(--blue); + border-right: 1px solid #10151c; +} + +.depth-ask { + background: var(--red); +} + +.depth-values { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.28rem; + color: #dce2e9; + font-size: 0.57rem; + text-shadow: 0 1px #080b0f; +} + +.quote-age { + color: #aab3bf; +} + +.quote-age-warm { + color: var(--amber); +} + +.quote-age-stale { + color: var(--red); +} + +.quote-up { + color: var(--green); +} + +.quote-down { + color: var(--red); +} + +.sparkline { + display: block; + width: 8rem; + height: 1.2rem; + margin-left: auto; + overflow: visible; +} + +.sparkline polyline { + fill: none; + stroke: var(--blue-soft); + stroke-linecap: square; + stroke-linejoin: miter; + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} + +.market-statusbar { + display: flex; + flex: 0 0 25px; + gap: 1.1rem; + align-items: center; + padding: 0 0.65rem; + color: var(--muted); + background: #0d1117; + border-top: 1px solid var(--border); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.56rem; + letter-spacing: 0.035em; +} + +.market-statusbar strong { + margin-left: 0.25rem; + color: #c4ccd6; + font-weight: 500; +} + +.statusbar-spacer { + flex: 1; +} + +.configurator { + min-height: 0; + overflow: auto; + background: #0d1218; +} + +.configurator > header { + display: flex; + height: 42px; + align-items: center; + justify-content: space-between; + padding: 0 0.75rem; + color: var(--text-strong); + background: #11171f; + border-bottom: 1px solid var(--border); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.055em; +} + +.configurator > header small { + color: var(--muted); + font-size: 0.54rem; + font-weight: 500; +} + +.config-section { + display: grid; + gap: 0.7rem; + padding: 0.8rem 0.75rem; + border-bottom: 1px solid var(--border); +} + +.config-section h2 { + margin: 0; + color: #8e99a6; + font-size: 0.58rem; + letter-spacing: 0.075em; +} + +.primary-action { + width: 100%; + color: #fff; + background: #316fdd; + border-color: #4f8cff; +} + +.primary-action:hover { + background: #397bed; + border-color: #73a2ff; +} + +.field { + display: grid; + gap: 0.35rem; + color: #9ba5b1; + font-size: 0.66rem; +} + +.rate-field > span { + display: flex; + justify-content: space-between; +} + +.rate-field strong { + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-weight: 500; +} + +.field small { + color: #66717e; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.55rem; +} + +input[type='range'] { + width: 100%; + margin: 0; + accent-color: var(--blue); +} + +.toggle-field { + display: flex; + gap: 0.5rem; + align-items: flex-start; + color: #b8c0ca; + font-size: 0.66rem; + line-height: 1.3; +} + +.toggle-field input { + margin: 0.12rem 0 0; + accent-color: var(--blue); +} + +.toggle-field small { + display: block; + margin-top: 0.14rem; + color: #687482; + font-size: 0.57rem; +} + +.action-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.4rem; +} + +.diagnostics dl, +.selected-instrument dl { + display: grid; + gap: 0; + margin: 0; +} + +.diagnostics dl > div, +.selected-instrument dl > div { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 24px; + border-bottom: 1px solid var(--border-soft); +} + +.diagnostics dl > div:last-child, +.selected-instrument dl > div:last-child { + border-bottom: 0; +} + +dt { + color: var(--muted); + font-size: 0.61rem; +} + +dd { + margin: 0; + color: #d4dae2; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.62rem; +} + +.selection { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.selection div { + display: grid; + gap: 0.18rem; +} + +.selection strong { + color: var(--text-strong); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85rem; +} + +.selection span, +.selection small, +.selected-instrument p { + color: var(--muted); + font-size: 0.61rem; +} + +.selected-instrument p { + margin: 0; + line-height: 1.45; +} + +@media (max-width: 980px) { + .workspace { + grid-template-columns: minmax(0, 1fr) 250px; + } + + .market-context span:not(:first-child) { + display: none; + } + + .metrics-strip { + grid-template-columns: repeat(3, 1fr); + flex-basis: 166px; + } +} + +@media (max-width: 720px) { + html, + body { + min-height: 100%; + } + + body { + overflow: auto; + } + + .trading-terminal { + height: auto; + min-height: 100vh; + overflow: visible; + } + + .session-info > span:first-child { + display: none; + } + + .workspace { + display: flex; + flex-direction: column; + } + + .market-panel { + min-height: 68vh; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .metrics-strip { + grid-template-columns: repeat(2, 1fr); + flex-basis: 220px; + } + + .table-scroll { + height: 58vh; + flex: none; + } + + .market-context { + display: none; + } + + .configurator { + overflow: visible; + } +} diff --git a/examples/angular/realtime-trading/tests/e2e/smoke.spec.ts b/examples/angular/realtime-trading/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000000..86283a6826 --- /dev/null +++ b/examples/angular/realtime-trading/tests/e2e/smoke.spec.ts @@ -0,0 +1,139 @@ +import path from 'node:path' +import { expect, test } from '@playwright/test' +import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' + +const exampleDir = path.resolve() + +function collectPageErrors(page: Page) { + const errors: Array = [] + + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + + return errors +} + +test('runs the same workload across all three table adapters', async ({ + page, +}) => { + const server = await startExampleServer(exampleDir) + const errors = collectPageErrors(page) + + try { + await page.goto(server.url) + + const table = page.getByRole('table') + await expect(table).toBeVisible() + await expect(table.locator('tbody tr')).toHaveCount(250) + await expect(table.locator('thead th')).toHaveCount(14) + await expect(table.locator('thead')).toContainText('Ticker') + await expect(table.locator('thead')).toContainText('Last Qty') + await expect(table.locator('thead')).toContainText('Traded Value') + + const adapter = page.getByTestId('adapter-select') + await expect(adapter).toHaveValue('local') + await expect(page.getByTestId('feed-status')).toHaveText('FEED LIVE') + const instrumentCount = page.getByTestId('instrument-count-select') + await expect(instrumentCount.locator('option[value="150"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="350"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="750"]')).toHaveCount(1) + + const loadProfile = page.getByTestId('load-profile-select') + await expect(loadProfile).toHaveValue('high') + await loadProfile.selectOption('very-high') + await expect(page.getByTestId('target-rate-slider')).toHaveValue('25000') + await loadProfile.selectOption('high') + + const rowWorkload = page.getByTestId('row-workload-select') + await rowWorkload.selectOption('rotating-filter') + await expect(table.locator('tbody tr')).toHaveCount(200) + await rowWorkload.selectOption('identity-churn') + await expect(table.locator('tbody tr')).toHaveCount(250) + await expect( + table.locator('tbody tr[data-row-id*="-replacement-"]'), + ).toHaveCount(25) + await rowWorkload.selectOption('price-sort') + await expect(table.locator('tbody tr')).toHaveCount(250) + await rowWorkload.selectOption('stable') + + await expect + .poll(async () => { + const text = await page.getByTestId('total-events').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => { + const text = await page.getByTestId('worker-messages').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('raf-rate').textContent()), + ) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('table-render-rate').textContent()), + ) + .toBeGreaterThan(0) + await expect(page.getByTestId('long-frame-count')).toHaveText( + /^(?:N\/A|\d+)$/, + ) + + const tableWorker = page.getByTestId('table-worker-toggle') + await expect(tableWorker).toBeEnabled() + await tableWorker.check() + await expect(page.getByText('ROW MODEL WORKER ON')).toBeVisible() + await expect(table.locator('tbody tr')).toHaveCount(250) + await page.getByTestId('feed-toggle').click() + await expect + .poll(() => + page + .locator('app-worker-trading-table') + .getAttribute('data-table-worker-compute-ms'), + ) + .not.toBeNull() + await expect(page.locator('app-worker-trading-table')).toHaveAttribute( + 'data-table-worker-pending', + 'false', + ) + await page.getByTestId('feed-toggle').click() + await tableWorker.uncheck() + + for (const implementation of ['beta', 'v8', 'local']) { + await adapter.selectOption(implementation) + await expect(adapter).toHaveValue(implementation) + if (implementation === 'local') { + await expect(tableWorker).toBeEnabled() + } else { + await expect(tableWorker).toBeDisabled() + } + await expect(page.getByRole('table')).toBeVisible() + await expect(page.locator('tbody tr')).toHaveCount(250) + + const firstPrice = page.locator('tbody tr').first().getByRole('button') + const priceBeforeUpdate = await firstPrice.textContent() + await expect + .poll(() => firstPrice.textContent()) + .not.toBe(priceBeforeUpdate) + } + + await page + .getByLabel( + 'Swap Tick component A โ†” B destroy and recreate when direction changes', + ) + .check() + await page.getByTestId('feed-toggle').click() + await expect(page.getByTestId('feed-toggle')).toHaveText('START FEED') + await expect(page.getByTestId('feed-status')).toHaveText('FEED PAUSED') + + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) diff --git a/examples/angular/realtime-trading/tsconfig.app.json b/examples/angular/realtime-trading/tsconfig.app.json new file mode 100644 index 0000000000..8426ad9558 --- /dev/null +++ b/examples/angular/realtime-trading/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/examples/angular/realtime-trading/tsconfig.json b/examples/angular/realtime-trading/tsconfig.json new file mode 100644 index 0000000000..9ff0bce067 --- /dev/null +++ b/examples/angular/realtime-trading/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "include": ["**/*.ts", "tests/e2e"] +} diff --git a/examples/react/realtime-trading/README.md b/examples/react/realtime-trading/README.md new file mode 100644 index 0000000000..4570f701a4 --- /dev/null +++ b/examples/react/realtime-trading/README.md @@ -0,0 +1,324 @@ +# React real-time trading FlexRender lab + +This standalone example generates deterministic synthetic quote events in the +browser and stresses two React Table render paths. It is not a real +exchange feed and does not display financial advice or real market data. + +The workload is inspired by the public +[AG Grid finance demo](https://www.ag-grid.com/example-finance/) and its +[source](https://github.com/ag-grid/ag-grid-demos/tree/main/finance), but is +focused on renderer lifecycle, immutable high-frequency updates, backpressure, +and browser performance APIs. + +## Run it + +From the repository root: + +```sh +pnpm --filter tanstack-react-table-example-realtime-trading dev +``` + +Open `http://localhost:7778`. For measurements, build and serve the production +bundle so development checks do not distort the result. Use the dedicated +profiling build when the in-page React Profiler metrics are part of the run: + +```sh +pnpm --filter tanstack-react-table-example-realtime-trading build:profile +pnpm --filter tanstack-react-table-example-realtime-trading serve +``` + +The regular `build` command deliberately keeps React's smaller standard +production bundle. React disables `` callbacks in that build, so the +widget labels its commit timings as unavailable. `build:profile` aliases the +root renderer to React's production profiling bundle. + +Like the other React examples in this repository, `index.html` also loads the +React Scan widget from `https://unpkg.com/react-scan/dist/auto.global.js`. +React Scan provides the visual rerender overlay and interactive diagnostics; +the in-page Profiler metrics provide repeatable numeric samples. Keep React +Scan enabled while locating unnecessary rerenders, then disable or stub it when +recording final adapter comparisons because its instrumentation adds work. + +## What is copied into this example + +This directory is self-contained. It does not import the feed implementation, +styles, components, or benchmark shell from the Angular or Solid examples: + +- `market-feed-engine.ts` owns the deterministic quote algorithm. +- `market-feed.worker.ts` schedules, batches, coalesces, and applies + backpressure. +- `market-feed-protocol.ts` defines commands and events across the worker + boundary. +- `market-data.ts` creates a new data array and new objects for changed rows. +- `quote-cells.tsx` implements all dynamic React cells and lifecycle counters. +- `trading-table-local.tsx` and `trading-table-v8.tsx` isolate the two adapter + implementations. +- `trading-table-shared.tsx` owns the stable column definition, leaf-level + Store subscriptions, shared types, and row-model measurement. +- `core/trading-benchmark-controller.ts` owns the TanStack Store, worker + transport, derived table inputs, and user commands. +- `core/use-trading-benchmark-controller.ts` only binds that stable controller + to the React mount lifecycle. +- `core/use-trading-table-runtime.ts` gives each adapter only its own data and + benchmark subscriptions. +- `benchmark/benchmark-monitor.ts` owns Profiler samples, browser observers, + render acknowledgements, scroll pressure, and published metrics. +- `benchmark/use-table-benchmark.ts` binds table commits, DOM mutation + observation, and automated scroll pressure to the mounted adapter. +- `shell/TradingShell.tsx` composes independent header, toolbar, metrics, + status-bar, diagnostics, and configurator components. +- `shell/trading-shell-context.tsx` lets every shell component consume the + controller without prop drilling. +- `App.tsx` creates the controller and owns only the projected adapter switch. +- `index.css` is a complete local copy of the trading-terminal styles. + +The duplication is intentional: each framework example can be copied, changed, +or profiled on its own. Keep the workload files synchronized manually when +making cross-framework comparisons. + +## Adapter matrix + +The configurator mounts exactly one implementation at a time: + +- **Local v9** imports the React adapter from this workspace. +- **8.21.3** imports the final published React v8 adapter and matching core. + +Changing the select unmounts the current table before mounting the next one. +Feed state remains in the controller, so both receive the same immutable +snapshots. +The exact v8 tarball alias prevents this workspace from silently replacing the +published baseline with local packages. + +## React Compiler + +The Vite build enables React Compiler through `reactCompilerPreset`. The local +v9 table component uses the compiler and its emitted function contains React +memo-cache code. + +The v8 adapter lives in its own module with a module-level `'use no memo'` +directive and retains its explicit `memo` wrapper. This prevents compiler +optimization from interacting with v8's mutable table instance while keeping +the rest of the example compiled. + +The benchmark-only `recordCellRender` and lifecycle-counter functions also use +function-level `'use no memo'`. They intentionally mutate write-only diagnostic +counters on every invocation; compiling or memoizing that instrumentation +would change the measurement. This is safe only because those counters do not +participate in rendered output. `recordCellRender` receives an already-evaluated +value and a typed column name rather than an anonymous callback. + +## Architecture + +`App` deliberately has no knowledge of workers, timers, data, or table render +options. It provides the stable controller, subscribes only to the adapter +choice, and projects that table into the shell layout. +`TradingBenchmarkController` subscribes to the feed worker, converts protocol +events into immutable row snapshots, derives the selected workload, and +exposes a TanStack Store plus stable actions. + +The React context transports only the controller identity. Components call +`useTradingShellState(selector)` to subscribe to the exact state slice they +render. Quote snapshots therefore update the table outlet without rerendering +the header or configurator, while the 500 ms performance snapshot updates only +the metrics strip, status bar, and diagnostics. Multi-field selectors use +TanStack Store's `shallow` comparator, so returning a small object does not +turn it back into a whole-store subscription. Controller operations that +publish several related fields use TanStack Store `batch`, exposing one +coherent snapshot to subscribers. + +Each adapter calls its own runtime hook. The local v9 `useTable` owner selects +`null`, so table state does not rerender the entire owner. Sorting and filtering +are external table atoms, and a `table.Subscribe` boundary rerenders only the +row-model body. The body receives the immutable quote snapshot as an explicit +input as well: external table-state subscriptions do not represent changes to +the `data` option, and this dependency prevents React Compiler from retaining +the initial empty body. Renderer mode, quote age, and selection are read by the +leaf cell/row components that use them; they no longer travel through the table +outlet or a broad render-options context. Quote data still rerenders the table +owner and body because `data` is a table option and the row model must process +a new immutable snapshot. + +Benchmark instrumentation is a separate collaborator. `BenchmarkMonitor` +contains the mutable sampling runtime and publishes immutable metric snapshots +to the same store. `useTableBenchmark` owns the React/DOM lifecycle bridge. +This keeps measurement policy out of both the table adapters and the +presentational shell. + +All deliberate mutable runtime is grouped behind `const` object or ref +identities. Source files do not use `let`; counters and handles change as +properties of those stable runtime owners instead of being scattered mutable +bindings. + +## Render workload + +The table contains 14 columns and up to 1,000 instruments. Its market-watch +labels are Ticker, Venue, Bid, Ask, Spread, Last, Last Move, Last Qty, +Bid / Ask Qty, Quote Age, Day %, Total Qty, Traded Value, and Intraday. It +combines: + +- primitive bid, ask, daily change, quantity, and traded-value cells; +- a clickable price component; +- a stable Tick component or two alternating Up/Down component types; +- spread, market-depth, quote-age, and sparkline components; +- immutable row replacement with stable instrument IDs; +- optional 100 ms quote-age invalidation for every visible Age cell; and +- optional history-array replacement for sparklines. + +The feed control provides repeatable load profiles: Low 1k/s, Medium 5k/s, +High 10k/s, Very high 25k/s, and Max 100k/s. High is the default; Max is a +deliberate saturation test. Moving the rate slider switches the profile to +Custom. Available universe sizes are 50, 100, 150, 250, 350, 500, 750, and +1,000. + +The row workload selector separates four different behaviors: + +- **Stable universe** preserves source order and IDs. +- **Continuously sort by Last** reorders keyed rows as prices move without + recreating their identity. +- **Rotate 20% filtered rows** changes one excluded index bucket each second, + forcing row removal and reinsertion. +- **Replace 10% of ticker IDs** changes one bucket's IDs and ticker labels each + second. Ten percent are replacements at any instant; transitions dispose the + previous bucket and create the next, crossing lifecycle boundaries for about + twenty percent of rows. + +These transformations run before the selected adapter so both versions +receive the same arrays. They test sorting/filtering consequences and keyed +reconciliation. + +The separate **TanStack core row model** selector exercises the adapters' +sorting and filtering APIs: + +- **Sort Last descending** installs and executes the sorted row model. +- **Filter Ticker** installs and executes the filtered row model with a + case-insensitive Ticker accessor. +- **Filter + sort Last** composes both row models. + +The local v9 and v8 adapters each configure their own version's factories. +This is intentionally independent of **Row workload**, so a run can +measure core row-model work alone or combine it with upstream identity and +ordering pressure. + +React `StrictMode` is deliberately omitted because its development-only mount +replay would contaminate the component create/destroy counters. The table +component is memoized so the 500 ms diagnostics publication does not by itself +rerender every table cell. Data, quote-clock, selection, and renderer changes +still drive the table normally. + +`useLifecycleCounter` increments the component-function counter during render, +then uses passive `useEffect` for committed create/destroy lifetimes. A layout +effect would not make these counters more accurate: it would synchronously add +work to every mounted dynamic cell before paint and contaminate the timing this +example is trying to observe. Function-call counters can include concurrent +retries or abandoned render attempts, while effect create/destroy counters +represent committed lifetimes with a short post-paint reporting delay. The +table-scoped React Profiler is the committed-render timing reference. + +## Worker transport + +The Worker acts like an external WebSocket/SSE transport. It permits one batch +in flight. React acknowledges a batch from `useLayoutEffect`, after the +corresponding commit. While a commit is pending, the Worker coalesces newer +events by instrument in a bounded map instead of growing an unbounded message +queue. + +`batch events` therefore counts source events, while `row updates` counts the +final row snapshots copied into a particular UI batch. A batch can contain +thousands of events but at most one final update per instrument. + +The Worker removes quote generation and batching from the main thread. React +rendering, TanStack row-model work, DOM reconciliation, layout, and paint still +run on the main thread. + +## Metrics + +- **Throughput** is the source-event rate represented by acknowledged batches. +- **RAF rate** is actual `requestAnimationFrame` callbacks divided by elapsed + wall time. It is not an FPS estimate derived from table renders. +- **Table renders** is the rate of feed batches that reach a completed React + commit and are acknowledged. +- **Average / P95 render** spans worker-message receipt through the layout + effect after commit. It excludes worker calculation and browser paint. +- **Long frames** uses the browser Long Animation Frames API when available. +- **Heap** uses Chromium's non-standard `performance.memory` when available. +- **Created / destroyed** distinguishes expected type-swap churn from live + component retention. +- **Cell renderer calls/s** counts executions of column `cell` functions. The + per-column breakdown identifies which callback was invoked. +- **Component renders/s** counts executions of the dynamic React cell + component functions. The per-type breakdown identifies the exact component; + development Strict Mode and abandoned renders can legitimately produce + function calls without matching DOM mutations. +- **DOM mutation records/s** comes from a `MutationObserver` attached to the + active `tbody`. It counts observer records, not changed elements or painted + pixels. +- **React Profiler commits/s** and actual/base durations come directly from a + `` wrapped around only the active table adapter. Actual duration is + the work performed for that commit; base duration is React's estimate of the + subtree cost without memoization. +- **Core row model calls/s** times `table.getRowModel()` around the actual rows + consumed by the render. It includes memo lookup overhead and, when inputs + change, sorting/filtering computation. +- **Automated scroll pressure** drives the real table scroll container + vertically, horizontally, or both. It reports callback rate, distance, and + frames delayed beyond 34 ms. + +The app also writes browser User Timing entries that appear in a Chrome +Performance recording: + +- `react-profiler-commit` +- `market-update-to-layout-commit` +- `tanstack-row-model` +- `benchmark:*` marks for adapter, core-mode, and scroll changes + +Entries are periodically cleared from the live performance buffer to keep the +benchmark instrumentation itself bounded. A DevTools recording still captures +the marks and measures that occurred while recording. + +The heap line is diagnostic. Immutable updates continuously allocate short-lived +arrays, row objects, and render objects, so raw heap can rise before garbage +collection. Compare post-GC plateaus in the browser Memory profiler. + +The three counters are deliberately simple but do add instrumentation overhead. +Use them to catch accidental full-table work and compare ratios. For final +timings, corroborate them with a Chrome Performance recording and React +DevTools Profiler. A realistic healthy run keeps source throughput near target, +avoids a cell-render rate equal to every cell on every worker batch, and reaches +a stable post-GC heap plateau. + +## Is this a real financial grid? + +The presentation and workload shape are realistic for a market-watch blotter, +but the prices, venues, sizes, volume, and traded value are deterministic +synthetic data. There is no order book, exchange calendar, corporate actions, +network jitter, reconnect logic, entitlement processing, or real WebSocket +decoder. That makes the lab reproducible, not production-representative. + +For higher confidence, replay a timestamped, sanitized capture through the same +worker protocol. Preserve burstiness and symbol skew, then compare adapters +using the same capture, production build, browser, viewport, and fixed +measurement window. + +## Repeatable comparison + +1. Use production builds in the same browser and on the same machine. +2. Start at 250 instruments and 10k events/s. +3. Keep stable Tick cells, quote ages, and sparklines enabled. +4. Reset and warm up for 20โ€“30 seconds. +5. Record throughput, RAF rate, table renders/s, cell/component calls, DOM + mutations, P95, long frames, and post-GC heap over a fixed window. +6. Repeat with the Angular and Solid standalone examples using identical + controls. +7. Toggle Tick A/B swapping, quote ages, and sparklines separately to isolate + component churn, shared-clock invalidation, and array-input cost. + +The 25k burst is useful for profiling coalescing and a large render, but a +sustained target rate is the better test of steady-state behavior. + +For a scroll run, select 750 or 1,000 instruments and start with vertical +pressure. This table is deliberately not virtualized: every row remains mounted, +so the scroll test primarily exposes browser style/layout/paint responsiveness +and main-thread contention. It is not a virtualization benchmark and should not +be interpreted as one. Add a separately named virtualized adapter if that +architecture needs comparison, since virtualization changes the amount of DOM +and React work rather than merely optimizing the same path. diff --git a/examples/react/realtime-trading/index.html b/examples/react/realtime-trading/index.html new file mode 100644 index 0000000000..812fb572fc --- /dev/null +++ b/examples/react/realtime-trading/index.html @@ -0,0 +1,17 @@ + + + + + + + React Table โ€” Real-time Trading Benchmark + + + +
+ + + diff --git a/examples/react/realtime-trading/package.json b/examples/react/realtime-trading/package.json new file mode 100644 index 0000000000..55a68bde1f --- /dev/null +++ b/examples/react/realtime-trading/package.json @@ -0,0 +1,32 @@ +{ + "name": "tanstack-react-table-example-realtime-trading", + "private": true, + "type": "module", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "build:profile": "vite build --mode profile", + "serve": "vite preview", + "lint": "eslint ./src", + "test:types": "tsc --noEmit", + "test:e2e": "PLAYWRIGHT_TEST_DIR=$PWD/tests/e2e playwright test --config ../../../playwright.config.ts" + }, + "dependencies": { + "@tanstack/react-store": "^0.11.0", + "@tanstack/react-table": "^9.0.0", + "@tanstack/react-table-v8": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@rolldown/plugin-babel": "^0.2.3", + "@rollup/plugin-replace": "^6.0.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "babel-plugin-react-compiler": "^1.0.0", + "typescript": "6.0.3", + "vite": "^8.2.0" + } +} diff --git a/examples/react/realtime-trading/src/App.tsx b/examples/react/realtime-trading/src/App.tsx new file mode 100644 index 0000000000..b1adde339d --- /dev/null +++ b/examples/react/realtime-trading/src/App.tsx @@ -0,0 +1,41 @@ +import { Profiler } from 'react' +import { useTradingBenchmarkController } from './core/use-trading-benchmark-controller' +import { TradingShell } from './shell/TradingShell' +import { + TradingShellProvider, + useTradingShellController, + useTradingShellState, +} from './shell/trading-shell-context' +import { + LocalTradingTable, + V8TradingTable, +} from './trading-table' + +export function App() { + const controller = useTradingBenchmarkController() + return ( + + + + + + ) +} + +function TradingTableOutlet() { + const controller = useTradingShellController() + const tableAdapter = useTradingShellState((state) => state.tableAdapter) + const ActiveTradingTable = + tableAdapter === 'local' + ? LocalTradingTable + : V8TradingTable + + return ( + + + + ) +} diff --git a/examples/react/realtime-trading/src/benchmark-profiles.ts b/examples/react/realtime-trading/src/benchmark-profiles.ts new file mode 100644 index 0000000000..848fc28880 --- /dev/null +++ b/examples/react/realtime-trading/src/benchmark-profiles.ts @@ -0,0 +1,65 @@ +import type { MarketQuote } from './market-data' + +export type FeedLoadProfile = + 'low' | 'medium' | 'high' | 'very-high' | 'max' | 'custom' + +export type RowWorkloadMode = + 'stable' | 'price-sort' | 'rotating-filter' | 'identity-churn' + +export const feedLoadRates: Record< + Exclude, + number +> = { + low: 1_000, + medium: 5_000, + high: 10_000, + 'very-high': 25_000, + max: 100_000, +} + +export function deriveBenchmarkQuotes( + quotes: Array, + mode: RowWorkloadMode, + epoch: number, +): Array { + if (mode === 'stable') { + return quotes + } + + if (mode === 'price-sort') { + return [...quotes].sort( + (left, right) => + right.price - left.price || left.symbol.localeCompare(right.symbol), + ) + } + + if (mode === 'rotating-filter') { + const excludedBucket = epoch % 5 + return quotes.filter((_, index) => index % 5 !== excludedBucket) + } + + const replacementBucket = epoch % 10 + return quotes.map((quote, index) => + index % 10 === replacementBucket + ? { + ...quote, + id: `${quote.id}-replacement-${epoch}`, + symbol: `${quote.symbol}R${epoch % 100}`, + company: `${quote.company} replacement`, + } + : quote, + ) +} + +export function rowWorkloadLabel(mode: RowWorkloadMode): string { + switch (mode) { + case 'price-sort': + return 'PRICE REORDER' + case 'rotating-filter': + return 'FILTER ROTATION' + case 'identity-churn': + return 'TICKER REPLACEMENT' + default: + return 'STABLE UNIVERSE' + } +} diff --git a/examples/react/realtime-trading/src/benchmark/benchmark-monitor.ts b/examples/react/realtime-trading/src/benchmark/benchmark-monitor.ts new file mode 100644 index 0000000000..329649bc27 --- /dev/null +++ b/examples/react/realtime-trading/src/benchmark/benchmark-monitor.ts @@ -0,0 +1,445 @@ +import { quoteCellLifecycle, quoteRenderDiagnostics } from '../quote-cells' +import { rowModelDiagnostics } from '../trading-table' +import type { ProfilerOnRenderCallback } from 'react' +import type { MarketFeedCommand } from '../market-feed-protocol' +import type { TableAdapter } from '../trading-table' + +export interface NamedInvocationRate { + name: string + callsPerSecond: number +} + +export interface FeedMetrics { + actualEventsPerSecond: number + totalEvents: number + rafCallbacksPerSecond: number + tableRendersPerSecond: number + lastBatchSize: number + averageRenderMs: number + p95RenderMs: number + maxRenderMs: number + slowRenders: number + longAnimationFrames: number + worstLongAnimationFrameMs: number + heapMb: number | null + componentsCreated: number + componentsDestroyed: number + workerMessages: number + lastUpdateCount: number + cellRendererCallsPerSecond: number + componentRenderCallsPerSecond: number + cellRendererRates: ReadonlyArray + componentRenderRates: ReadonlyArray + domMutationsPerSecond: number + profilerCommitsPerSecond: number + profilerAverageActualMs: number + profilerP95ActualMs: number + profilerAverageBaseMs: number + rowModelCallsPerSecond: number + rowModelAverageMs: number + rowModelMaxMs: number + visibleRows: number + scrollCallbacksPerSecond: number + scrollDistancePerSecond: number + scrollJankFrames: number +} + +export type ScrollStressMode = 'off' | 'vertical' | 'horizontal' | 'both' + +export const initialMetrics: FeedMetrics = { + actualEventsPerSecond: 0, + totalEvents: 0, + rafCallbacksPerSecond: 0, + tableRendersPerSecond: 0, + lastBatchSize: 0, + averageRenderMs: 0, + p95RenderMs: 0, + maxRenderMs: 0, + slowRenders: 0, + longAnimationFrames: 0, + worstLongAnimationFrameMs: 0, + heapMb: null, + componentsCreated: 0, + componentsDestroyed: 0, + workerMessages: 0, + lastUpdateCount: 0, + cellRendererCallsPerSecond: 0, + componentRenderCallsPerSecond: 0, + cellRendererRates: [], + componentRenderRates: [], + domMutationsPerSecond: 0, + profilerCommitsPerSecond: 0, + profilerAverageActualMs: 0, + profilerP95ActualMs: 0, + profilerAverageBaseMs: 0, + rowModelCallsPerSecond: 0, + rowModelAverageMs: 0, + rowModelMaxMs: 0, + visibleRows: 0, + scrollCallbacksPerSecond: 0, + scrollDistancePerSecond: 0, + scrollJankFrames: 0, +} + +const userTiming = { entryCount: 0 } + +export function recordMeasure( + name: string, + start: number, + end: number, + detail: Record, +): void { + try { + performance.measure(name, { start, end, detail }) + userTiming.entryCount++ + if (userTiming.entryCount % 1_000 === 0) { + performance.clearMeasures('react-profiler-commit') + performance.clearMeasures('market-update-to-layout-commit') + } + } catch { + // User Timing Level 3 options are not implemented in every browser. + } +} + +export function markBenchmarkAction( + name: string, + detail: Record = {}, +): void { + try { + performance.mark(`benchmark:${name}`, { detail }) + userTiming.entryCount++ + if (userTiming.entryCount % 1_000 === 0) { + performance.clearMarks() + } + } catch { + // User Timing Level 3 detail is not implemented in every browser. + } +} + +interface PendingAck { + generation: number + sequence: number +} + +interface ProfilerSample { + actualDuration: number + baseDuration: number +} + +export class BenchmarkMonitor { + readonly #runtime = { + sampleStartedAt: performance.now(), + pendingRenderStartedAt: null as number | null, + pendingAck: null as PendingAck | null, + renderSamples: [] as Array, + profilerSamples: [] as Array, + totalEvents: 0, + eventsInSample: 0, + lastBatchSize: 0, + lastUpdateCount: 0, + workerMessages: 0, + rafCallbacksInSample: 0, + tableRendersInSample: 0, + longAnimationFrameCount: 0, + worstLongAnimationFrameMs: 0, + previousCellRendererCalls: 0, + previousComponentRenderCalls: 0, + previousCellRendererCallsByName: { + ...quoteRenderDiagnostics.cellRendererCallsByName, + }, + previousComponentRenderCallsByName: { + ...quoteRenderDiagnostics.componentRenderCallsByName, + }, + domMutationsInSample: 0, + previousRowModelCalls: 0, + previousRowModelDuration: 0, + scrollCallbacksInSample: 0, + scrollDistanceInSample: 0, + scrollJankFramesInSample: 0, + } + + readonly recordProfilerRender: ProfilerOnRenderCallback = ( + id, + phase, + actualDuration, + baseDuration, + startTime, + commitTime, + ) => { + this.#runtime.profilerSamples.push({ actualDuration, baseDuration }) + recordMeasure('react-profiler-commit', startTime, commitTime, { + id, + phase, + actualDuration, + baseDuration, + }) + } + + markRenderPending(): void { + this.#runtime.pendingRenderStartedAt ??= performance.now() + } + + setPendingAck(ack: PendingAck | null): void { + this.#runtime.pendingAck = ack + } + + recordCompletedRender( + adapter: TableAdapter, + postCommand: (command: MarketFeedCommand) => void, + ): void { + const runtime = this.#runtime + if (runtime.pendingRenderStartedAt !== null) { + const renderEndedAt = performance.now() + runtime.renderSamples.push( + renderEndedAt - runtime.pendingRenderStartedAt, + ) + recordMeasure( + 'market-update-to-layout-commit', + runtime.pendingRenderStartedAt, + renderEndedAt, + { adapter }, + ) + runtime.pendingRenderStartedAt = null + } + + if (runtime.pendingAck) { + runtime.tableRendersInSample++ + postCommand({ type: 'ack', ...runtime.pendingAck }) + runtime.pendingAck = null + } + } + + recordBatch(eventCount: number, updateCount: number): void { + const runtime = this.#runtime + runtime.lastBatchSize = eventCount + runtime.lastUpdateCount = updateCount + runtime.eventsInSample += eventCount + runtime.totalEvents += eventCount + runtime.workerMessages++ + } + + recordAnimationFrame(): void { + this.#runtime.rafCallbacksInSample++ + } + + recordLongAnimationFrame(duration: number): void { + const runtime = this.#runtime + runtime.longAnimationFrameCount++ + runtime.worstLongAnimationFrameMs = Math.max( + runtime.worstLongAnimationFrameMs, + duration, + ) + } + + recordDomMutations(count: number): void { + this.#runtime.domMutationsInSample += count + } + + resetDomMutations(): void { + this.#runtime.domMutationsInSample = 0 + } + + recordScrollFrame(distance: number, delayed: boolean): void { + const runtime = this.#runtime + runtime.scrollCallbacksInSample++ + runtime.scrollDistanceInSample += distance + if (delayed) { + runtime.scrollJankFramesInSample++ + } + } + + shouldPublish(now: number): boolean { + return now - this.#runtime.sampleStartedAt >= 500 + } + + publish(now: number): FeedMetrics { + const runtime = this.#runtime + const sampleDuration = now - runtime.sampleStartedAt + const renderSamples = runtime.renderSamples + const sortedRenderSamples = [...renderSamples].sort( + (left, right) => left - right, + ) + const profilerSamples = runtime.profilerSamples + const sortedProfilerSamples = profilerSamples + .map((sample) => sample.actualDuration) + .sort((left, right) => left - right) + const rowModelCalls = + rowModelDiagnostics.calls - runtime.previousRowModelCalls + const rowModelDuration = + rowModelDiagnostics.totalDurationMs - + runtime.previousRowModelDuration + const cellRendererCalls = + quoteRenderDiagnostics.cellRendererCalls - + runtime.previousCellRendererCalls + const componentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls - + runtime.previousComponentRenderCalls + const cellRendererRates = calculateInvocationRates( + quoteRenderDiagnostics.cellRendererCallsByName, + runtime.previousCellRendererCallsByName, + sampleDuration, + ) + const componentRenderRates = calculateInvocationRates( + quoteRenderDiagnostics.componentRenderCallsByName, + runtime.previousComponentRenderCallsByName, + sampleDuration, + ) + const averageRenderMs = + renderSamples.length === 0 + ? 0 + : renderSamples.reduce((sum, value) => sum + value, 0) / + renderSamples.length + const p95Index = Math.max( + 0, + Math.ceil(sortedRenderSamples.length * 0.95) - 1, + ) + const profilerP95Index = Math.max( + 0, + Math.ceil(sortedProfilerSamples.length * 0.95) - 1, + ) + const metrics: FeedMetrics = { + actualEventsPerSecond: + sampleDuration === 0 + ? 0 + : (runtime.eventsInSample / sampleDuration) * 1_000, + totalEvents: runtime.totalEvents, + rafCallbacksPerSecond: + (runtime.rafCallbacksInSample / sampleDuration) * 1_000, + tableRendersPerSecond: + (runtime.tableRendersInSample / sampleDuration) * 1_000, + lastBatchSize: runtime.lastBatchSize, + averageRenderMs, + p95RenderMs: sortedRenderSamples[p95Index] ?? 0, + maxRenderMs: sortedRenderSamples.at(-1) ?? 0, + slowRenders: renderSamples.filter((value) => value > 16.7).length, + longAnimationFrames: runtime.longAnimationFrameCount, + worstLongAnimationFrameMs: runtime.worstLongAnimationFrameMs, + heapMb: readHeapSizeMb(), + componentsCreated: quoteCellLifecycle.created, + componentsDestroyed: quoteCellLifecycle.destroyed, + workerMessages: runtime.workerMessages, + lastUpdateCount: runtime.lastUpdateCount, + cellRendererCallsPerSecond: + (cellRendererCalls / sampleDuration) * 1_000, + componentRenderCallsPerSecond: + (componentRenderCalls / sampleDuration) * 1_000, + cellRendererRates, + componentRenderRates, + domMutationsPerSecond: + (runtime.domMutationsInSample / sampleDuration) * 1_000, + profilerCommitsPerSecond: + (profilerSamples.length / sampleDuration) * 1_000, + profilerAverageActualMs: + profilerSamples.length === 0 + ? 0 + : profilerSamples.reduce( + (sum, sample) => sum + sample.actualDuration, + 0, + ) / profilerSamples.length, + profilerP95ActualMs: + sortedProfilerSamples[profilerP95Index] ?? 0, + profilerAverageBaseMs: + profilerSamples.length === 0 + ? 0 + : profilerSamples.reduce( + (sum, sample) => sum + sample.baseDuration, + 0, + ) / profilerSamples.length, + rowModelCallsPerSecond: (rowModelCalls / sampleDuration) * 1_000, + rowModelAverageMs: + rowModelCalls === 0 ? 0 : rowModelDuration / rowModelCalls, + rowModelMaxMs: rowModelDiagnostics.maxDurationMs, + visibleRows: rowModelDiagnostics.lastRowCount, + scrollCallbacksPerSecond: + (runtime.scrollCallbacksInSample / sampleDuration) * 1_000, + scrollDistancePerSecond: + (runtime.scrollDistanceInSample / sampleDuration) * 1_000, + scrollJankFrames: runtime.scrollJankFramesInSample, + } + + runtime.sampleStartedAt = now + runtime.previousCellRendererCalls = + quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.profilerSamples = [] + runtime.previousRowModelCalls = rowModelDiagnostics.calls + runtime.previousRowModelDuration = rowModelDiagnostics.totalDurationMs + runtime.scrollCallbacksInSample = 0 + runtime.scrollDistanceInSample = 0 + runtime.scrollJankFramesInSample = 0 + runtime.eventsInSample = 0 + runtime.renderSamples = [] + runtime.rafCallbacksInSample = 0 + runtime.tableRendersInSample = 0 + return metrics + } + + reset(): void { + const runtime = this.#runtime + runtime.sampleStartedAt = performance.now() + runtime.pendingRenderStartedAt = null + runtime.pendingAck = null + runtime.renderSamples = [] + runtime.profilerSamples = [] + runtime.totalEvents = 0 + runtime.eventsInSample = 0 + runtime.lastBatchSize = 0 + runtime.lastUpdateCount = 0 + runtime.workerMessages = 0 + runtime.rafCallbacksInSample = 0 + runtime.tableRendersInSample = 0 + runtime.longAnimationFrameCount = 0 + runtime.worstLongAnimationFrameMs = 0 + runtime.previousCellRendererCalls = + quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.previousRowModelCalls = rowModelDiagnostics.calls + runtime.previousRowModelDuration = rowModelDiagnostics.totalDurationMs + runtime.scrollCallbacksInSample = 0 + runtime.scrollDistanceInSample = 0 + runtime.scrollJankFramesInSample = 0 + rowModelDiagnostics.maxDurationMs = 0 + } +} + +export const longAnimationFramesSupported = + PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') + +function readHeapSizeMb(): number | null { + const memory = ( + performance as Performance & { + memory?: { usedJSHeapSize: number } + } + ).memory + return memory ? memory.usedJSHeapSize / 1_048_576 : null +} + +function calculateInvocationRates( + current: Record, + previous: Record, + sampleDuration: number, +): ReadonlyArray { + return Object.entries(current).map(([name, calls]) => ({ + name, + callsPerSecond: + sampleDuration === 0 + ? 0 + : ((calls - (previous[name] ?? 0)) / sampleDuration) * 1_000, + })) +} diff --git a/examples/react/realtime-trading/src/benchmark/use-table-benchmark.ts b/examples/react/realtime-trading/src/benchmark/use-table-benchmark.ts new file mode 100644 index 0000000000..b0410938ed --- /dev/null +++ b/examples/react/realtime-trading/src/benchmark/use-table-benchmark.ts @@ -0,0 +1,115 @@ +import { useEffect, useLayoutEffect } from 'react' +import { markBenchmarkAction } from './benchmark-monitor' +import type { ScrollStressMode } from './benchmark-monitor' +import type { TradingBenchmarkController } from '../core/trading-benchmark-controller' +import type { TableAdapter } from '../trading-table' + +export function useTableBenchmark( + controller: TradingBenchmarkController, + tableAdapter: TableAdapter, + scrollStressMode: ScrollStressMode, +): void { + 'use no memo' + useLayoutEffect(() => { + controller.recordCompletedRender() + }) + + useEffect(() => { + const tableBody = document.querySelector( + '.market-panel [data-table-adapter] tbody', + ) + if (!tableBody) { + return + } + + controller.monitor.resetDomMutations() + const observer = new MutationObserver((records) => { + controller.monitor.recordDomMutations(records.length) + }) + observer.observe(tableBody, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }) + return () => observer.disconnect() + }, [controller, tableAdapter]) + + useEffect(() => { + const scrollContainer = document.querySelector( + '.market-panel [data-table-adapter]', + ) + if (!scrollContainer) { + return + } + + if (scrollStressMode === 'off') { + scrollContainer.scrollTop = 0 + scrollContainer.scrollLeft = 0 + return + } + + markBenchmarkAction('scroll-start', { + mode: scrollStressMode, + adapter: tableAdapter, + }) + const runtime = { + animationFrameId: 0, + previousFrameAt: performance.now(), + verticalDirection: 1, + horizontalDirection: 1, + } + const scrollFrame = (now: number): void => { + const rawElapsed = now - runtime.previousFrameAt + const elapsed = Math.min(rawElapsed, 50) + runtime.previousFrameAt = now + const previousTop = scrollContainer.scrollTop + const previousLeft = scrollContainer.scrollLeft + + if ( + scrollStressMode === 'vertical' || + scrollStressMode === 'both' + ) { + const maxTop = + scrollContainer.scrollHeight - scrollContainer.clientHeight + const candidateTop = + scrollContainer.scrollTop + + (runtime.verticalDirection * (700 * elapsed)) / 1_000 + const nextTop = Math.max(0, Math.min(maxTop, candidateTop)) + if (maxTop > 0) { + scrollContainer.scrollTop = nextTop + if (candidateTop >= maxTop || candidateTop <= 0) { + runtime.verticalDirection *= -1 + } + } + } + + if ( + scrollStressMode === 'horizontal' || + scrollStressMode === 'both' + ) { + const maxLeft = + scrollContainer.scrollWidth - scrollContainer.clientWidth + const candidateLeft = + scrollContainer.scrollLeft + + (runtime.horizontalDirection * (420 * elapsed)) / 1_000 + const nextLeft = Math.max(0, Math.min(maxLeft, candidateLeft)) + if (maxLeft > 0) { + scrollContainer.scrollLeft = nextLeft + if (candidateLeft >= maxLeft || candidateLeft <= 0) { + runtime.horizontalDirection *= -1 + } + } + } + + const distance = + Math.abs(scrollContainer.scrollTop - previousTop) + + Math.abs(scrollContainer.scrollLeft - previousLeft) + controller.monitor.recordScrollFrame(distance, rawElapsed > 34) + runtime.animationFrameId = requestAnimationFrame(scrollFrame) + } + + runtime.animationFrameId = requestAnimationFrame(scrollFrame) + return () => cancelAnimationFrame(runtime.animationFrameId) + }, [controller, scrollStressMode, tableAdapter]) +} diff --git a/examples/react/realtime-trading/src/core/trading-benchmark-controller.ts b/examples/react/realtime-trading/src/core/trading-benchmark-controller.ts new file mode 100644 index 0000000000..b853af88c3 --- /dev/null +++ b/examples/react/realtime-trading/src/core/trading-benchmark-controller.ts @@ -0,0 +1,532 @@ +import { batch, createAtom, createStore } from '@tanstack/react-store' +import { + BenchmarkMonitor, + initialMetrics, + longAnimationFramesSupported, + markBenchmarkAction, +} from '../benchmark/benchmark-monitor' +import { + deriveBenchmarkQuotes, + feedLoadRates, + rowWorkloadLabel, +} from '../benchmark-profiles' +import { applyMarketUpdates, hydrateMarketQuotes } from '../market-data' +import { TRADING_COLUMN_COUNT, rowModelDiagnostics } from '../trading-table' +import type { + FeedMetrics, + ScrollStressMode, +} from '../benchmark/benchmark-monitor' +import type { FeedLoadProfile, RowWorkloadMode } from '../benchmark-profiles' +import type { + MarketFeedCommand, + MarketFeedEvent, +} from '../market-feed-protocol' +import type { MarketQuote } from '../market-data' +import type { + CoreRowModelMode, + CoreTableState, + RendererMode, + TableAdapter, +} from '../trading-table' + +export interface TradingBenchmarkState { + workerReady: boolean + running: boolean + tableAdapter: TableAdapter + instrumentCount: number + feedLoadProfile: FeedLoadProfile + targetEventsPerSecond: number + rowWorkloadMode: RowWorkloadMode + rowWorkloadEpoch: number + coreRowModelMode: CoreRowModelMode + coreFilterValue: string + scrollStressMode: ScrollStressMode + rendererMode: RendererMode + updateSparklines: boolean + updateQuoteAges: boolean + quoteClock: number + quotes: Array + selectedSymbol: string | null + metrics: FeedMetrics + displayQuotes: Array + selectedQuote: MarketQuote | null + mountedCells: number + liveComponents: number + adapterLabel: string + workloadLabel: string + longAnimationFramesSupported: boolean +} + +export interface TradingBenchmarkActions { + toggleFeed: () => void + setInstrumentCount: (count: number) => void + setFeedLoadProfile: (profile: FeedLoadProfile) => void + setTargetEventsPerSecond: (rate: number) => void + setRowWorkloadMode: (mode: RowWorkloadMode) => void + setTableAdapter: (adapter: TableAdapter) => void + setCoreRowModelMode: (mode: CoreRowModelMode) => void + setCoreFilterValue: (value: string) => void + setScrollStressMode: (mode: ScrollStressMode) => void + setRendererMode: (mode: RendererMode) => void + setUpdateQuoteAges: (enabled: boolean) => void + setUpdateSparklines: (enabled: boolean) => void + selectSymbol: (symbol: string | null) => void + runBurst: () => void + resetMarket: () => void +} + +const initialState: TradingBenchmarkState = { + workerReady: false, + running: true, + tableAdapter: 'local', + instrumentCount: 250, + feedLoadProfile: 'high', + targetEventsPerSecond: 10_000, + rowWorkloadMode: 'stable', + rowWorkloadEpoch: 0, + coreRowModelMode: 'none', + coreFilterValue: 'ALP', + scrollStressMode: 'off', + rendererMode: 'stable', + updateSparklines: true, + updateQuoteAges: true, + quoteClock: Date.now(), + quotes: [], + selectedSymbol: null, + metrics: initialMetrics, + displayQuotes: [], + selectedQuote: null, + mountedCells: 0, + liveComponents: 0, + adapterLabel: 'LOCAL V9', + workloadLabel: rowWorkloadLabel('stable'), + longAnimationFramesSupported, +} + +export class TradingBenchmarkController { + readonly store = createStore(initialState) + readonly tableAtoms = { + sorting: createAtom([]), + columnFilters: createAtom([]), + } + readonly renderAtoms = { + selectedSymbol: createAtom(null), + rendererMode: createAtom('stable'), + quoteAge: createAtom({ + enabled: true, + clock: initialState.quoteClock, + }), + } + readonly monitor = new BenchmarkMonitor() + readonly actions: TradingBenchmarkActions + + readonly #runtime = { + worker: null as Worker | null, + animationFrameId: 0, + feedGeneration: 0, + lastAgeClockAt: performance.now(), + lastRowWorkloadAt: performance.now(), + longAnimationFrameObserver: null as PerformanceObserver | null, + resetWaitingForCommit: false, + resetSnapshotReady: false, + } + + constructor() { + this.actions = { + toggleFeed: () => { + const nextRunning = !this.store.get().running + this.#patch({ running: nextRunning }) + this.#postToWorker({ type: 'configure', running: nextRunning }) + }, + setInstrumentCount: (count) => { + batch(() => { + this.#patch({ instrumentCount: count }) + this.#setWorkloadState({ rowWorkloadEpoch: 0 }) + this.#resetWorkerMarket(count) + }) + }, + setFeedLoadProfile: (profile) => { + if (profile === 'custom') { + this.#patch({ feedLoadProfile: profile }) + return + } + const rate = feedLoadRates[profile] + this.#patch({ + feedLoadProfile: profile, + targetEventsPerSecond: rate, + }) + this.#postToWorker({ + type: 'configure', + targetEventsPerSecond: rate, + }) + }, + setTargetEventsPerSecond: (rate) => { + this.#patch({ + feedLoadProfile: 'custom', + targetEventsPerSecond: rate, + }) + this.#postToWorker({ + type: 'configure', + targetEventsPerSecond: rate, + }) + }, + setRowWorkloadMode: (mode) => { + this.#runtime.lastRowWorkloadAt = performance.now() + batch(() => { + this.#setWorkloadState({ + rowWorkloadMode: mode, + rowWorkloadEpoch: 0, + selectedSymbol: null, + }) + this.renderAtoms.selectedSymbol.set(null) + }) + }, + setTableAdapter: (adapter) => { + markBenchmarkAction('adapter-change', { adapter }) + this.#patch({ + tableAdapter: adapter, + adapterLabel: adapterLabel(adapter), + }) + }, + setCoreRowModelMode: (mode) => { + markBenchmarkAction('core-row-model-change', { mode }) + batch(() => { + this.#setSelectionState({ + coreRowModelMode: mode, + selectedSymbol: null, + }) + this.#syncCoreTableAtoms(mode, this.store.get().coreFilterValue) + this.renderAtoms.selectedSymbol.set(null) + }) + }, + setCoreFilterValue: (value) => { + batch(() => { + this.#patch({ coreFilterValue: value }) + this.#syncCoreTableAtoms( + this.store.get().coreRowModelMode, + value, + ) + }) + }, + setScrollStressMode: (mode) => { + this.#patch({ scrollStressMode: mode }) + }, + setRendererMode: (mode) => { + batch(() => { + this.#patch({ rendererMode: mode }) + this.renderAtoms.rendererMode.set(mode) + }) + }, + setUpdateQuoteAges: (enabled) => { + batch(() => { + this.#patch({ updateQuoteAges: enabled }) + this.renderAtoms.quoteAge.set((current) => ({ + ...current, + enabled, + })) + }) + }, + setUpdateSparklines: (enabled) => { + this.#patch({ updateSparklines: enabled }) + this.#postToWorker({ + type: 'configure', + updateSparklines: enabled, + }) + }, + selectSymbol: (symbol) => { + batch(() => { + this.#setSelectionState({ selectedSymbol: symbol }) + this.renderAtoms.selectedSymbol.set(symbol) + }) + }, + runBurst: () => { + this.#postToWorker({ type: 'burst', eventCount: 25_000 }) + }, + resetMarket: () => { + batch(() => { + this.monitor.reset() + this.#runtime.lastRowWorkloadAt = performance.now() + this.store.setState((state) => ({ + ...state, + selectedSymbol: null, + selectedQuote: null, + rowWorkloadEpoch: 0, + quoteClock: Date.now(), + metrics: { ...initialMetrics }, + mountedCells: 0, + liveComponents: 0, + })) + this.renderAtoms.selectedSymbol.set(null) + this.#resetWorkerMarket(this.store.get().instrumentCount) + }) + }, + } + } + + start(): () => void { + const worker = new Worker( + new URL('../market-feed.worker.ts', import.meta.url), + { type: 'module' }, + ) + const longAnimationFrameObserver = longAnimationFramesSupported + ? new PerformanceObserver((entries) => { + for (const entry of entries.getEntries()) { + this.monitor.recordLongAnimationFrame(entry.duration) + } + }) + : null + + this.#runtime.worker = worker + this.#runtime.longAnimationFrameObserver = longAnimationFrameObserver + worker.addEventListener('message', this.#handleWorkerMessage) + worker.addEventListener('error', this.#handleWorkerError) + longAnimationFrameObserver?.observe({ + type: 'long-animation-frame', + buffered: true, + }) + worker.postMessage({ + type: 'initialize', + rowCount: initialState.instrumentCount, + seed: 292, + running: initialState.running, + targetEventsPerSecond: initialState.targetEventsPerSecond, + updateSparklines: initialState.updateSparklines, + } satisfies MarketFeedCommand) + this.#runtime.animationFrameId = requestAnimationFrame(this.#feedFrame) + + return () => this.stop() + } + + stop(): void { + cancelAnimationFrame(this.#runtime.animationFrameId) + this.#runtime.longAnimationFrameObserver?.disconnect() + this.#runtime.worker?.removeEventListener( + 'message', + this.#handleWorkerMessage, + ) + this.#runtime.worker?.removeEventListener('error', this.#handleWorkerError) + this.#runtime.worker?.terminate() + this.#runtime.worker = null + this.#runtime.longAnimationFrameObserver = null + } + + recordCompletedRender(): void { + this.monitor.recordCompletedRender( + this.store.get().tableAdapter, + this.#postToWorker, + ) + if ( + this.#runtime.resetWaitingForCommit && + this.#runtime.resetSnapshotReady + ) { + this.#runtime.resetWaitingForCommit = false + this.#runtime.resetSnapshotReady = false + this.#postToWorker({ + type: 'configure', + running: this.store.get().running, + }) + } + } + + readonly #postToWorker = (command: MarketFeedCommand): void => { + this.#runtime.worker?.postMessage(command) + } + + readonly #handleWorkerMessage = ({ + data, + }: MessageEvent): void => { + if (data.type === 'ready') { + this.#runtime.feedGeneration = data.generation + if (this.#runtime.resetWaitingForCommit) { + this.#runtime.resetSnapshotReady = true + } + this.monitor.setPendingAck(null) + this.monitor.markRenderPending() + batch(() => { + this.#setQuotes(hydrateMarketQuotes(data.quotes)) + this.#patch({ workerReady: true }) + }) + return + } + + if (data.generation !== this.#runtime.feedGeneration) { + this.#postToWorker({ + type: 'ack', + generation: data.generation, + sequence: data.sequence, + }) + return + } + + this.monitor.markRenderPending() + this.#setQuotes(applyMarketUpdates(this.store.get().quotes, data.updates)) + this.monitor.recordBatch(data.eventCount, data.updates.length) + this.monitor.setPendingAck({ + generation: data.generation, + sequence: data.sequence, + }) + } + + readonly #handleWorkerError = (error: ErrorEvent): void => { + this.#patch({ workerReady: false, running: false }) + console.error('Market feed worker failed', error) + } + + readonly #feedFrame = (now: number): void => { + this.monitor.recordAnimationFrame() + const state = this.store.get() + batch(() => { + if ( + state.updateQuoteAges && + now - this.#runtime.lastAgeClockAt >= 100 + ) { + this.#runtime.lastAgeClockAt = now + const clock = Date.now() + this.#patch({ quoteClock: clock }) + this.renderAtoms.quoteAge.set((current) => ({ + ...current, + clock, + })) + } + + if ( + state.running && + (state.rowWorkloadMode === 'rotating-filter' || + state.rowWorkloadMode === 'identity-churn') && + now - this.#runtime.lastRowWorkloadAt >= 1_000 + ) { + this.monitor.markRenderPending() + this.#runtime.lastRowWorkloadAt = now + this.#setWorkloadState({ + rowWorkloadEpoch: state.rowWorkloadEpoch + 1, + }) + } + + if (this.monitor.shouldPublish(now)) { + this.#publishMetrics(this.monitor.publish(now)) + } + }) + this.#runtime.animationFrameId = requestAnimationFrame(this.#feedFrame) + } + + #patch(patch: Partial): void { + this.store.setState((state) => ({ ...state, ...patch })) + } + + #setQuotes(quotes: Array): void { + this.store.setState((state) => { + const displayQuotes = deriveBenchmarkQuotes( + quotes, + state.rowWorkloadMode, + state.rowWorkloadEpoch, + ) + return { + ...state, + quotes, + displayQuotes, + selectedQuote: findSelectedQuote( + displayQuotes, + state.selectedSymbol, + ), + } + }) + } + + #setWorkloadState( + patch: Partial< + Pick< + TradingBenchmarkState, + 'rowWorkloadMode' | 'rowWorkloadEpoch' | 'selectedSymbol' + > + >, + ): void { + this.store.setState((state) => { + const nextState = { ...state, ...patch } + const displayQuotes = deriveBenchmarkQuotes( + state.quotes, + nextState.rowWorkloadMode, + nextState.rowWorkloadEpoch, + ) + return { + ...nextState, + displayQuotes, + selectedQuote: findSelectedQuote( + displayQuotes, + nextState.selectedSymbol, + ), + workloadLabel: rowWorkloadLabel(nextState.rowWorkloadMode), + } + }) + } + + #setSelectionState( + patch: Partial< + Pick< + TradingBenchmarkState, + 'coreRowModelMode' | 'selectedSymbol' + > + >, + ): void { + this.store.setState((state) => { + const nextState = { ...state, ...patch } + return { + ...nextState, + selectedQuote: findSelectedQuote( + nextState.displayQuotes, + nextState.selectedSymbol, + ), + } + }) + } + + #publishMetrics(metrics: FeedMetrics): void { + const visibleRows = rowModelDiagnostics.hasMeasurement + ? rowModelDiagnostics.lastRowCount + : this.store.get().displayQuotes.length + this.#patch({ + metrics, + mountedCells: visibleRows * TRADING_COLUMN_COUNT, + liveComponents: + metrics.componentsCreated - metrics.componentsDestroyed, + }) + } + + #syncCoreTableAtoms( + mode: CoreRowModelMode, + filterValue: string, + ): void { + const sorts = mode === 'sort' || mode === 'sort-filter' + const trimmedFilter = filterValue.trim() + const filters = + (mode === 'filter' || mode === 'sort-filter') && + trimmedFilter.length > 0 + this.tableAtoms.sorting.set(() => + sorts ? [{ id: 'price', desc: true }] : [], + ) + this.tableAtoms.columnFilters.set(() => + filters ? [{ id: 'symbol', value: trimmedFilter }] : [], + ) + } + + #resetWorkerMarket(rowCount: number): void { + this.#patch({ workerReady: false }) + this.monitor.setPendingAck(null) + this.#runtime.resetWaitingForCommit = true + this.#runtime.resetSnapshotReady = false + this.#postToWorker({ type: 'configure', running: false }) + this.#postToWorker({ type: 'reset', rowCount, seed: 42 + rowCount }) + } +} + +function findSelectedQuote( + quotes: Array, + selectedSymbol: string | null, +): MarketQuote | null { + return ( + quotes.find((quote) => quote.symbol === selectedSymbol) ?? null + ) +} + +function adapterLabel(adapter: TableAdapter): string { + return adapter === 'local' ? 'LOCAL V9' : 'V8.21.3' +} diff --git a/examples/react/realtime-trading/src/core/use-trading-benchmark-controller.ts b/examples/react/realtime-trading/src/core/use-trading-benchmark-controller.ts new file mode 100644 index 0000000000..08cb3179a6 --- /dev/null +++ b/examples/react/realtime-trading/src/core/use-trading-benchmark-controller.ts @@ -0,0 +1,15 @@ +import { useEffect, useRef } from 'react' +import { TradingBenchmarkController } from './trading-benchmark-controller' + +export function useTradingBenchmarkController() { + 'use no memo' + const controllerRef = useRef(null) + controllerRef.current ??= new TradingBenchmarkController() + const controller = controllerRef.current + + useEffect(() => controller.start(), [controller]) + + return controller +} + +export type { TradingBenchmarkController } from './trading-benchmark-controller' diff --git a/examples/react/realtime-trading/src/core/use-trading-table-runtime.ts b/examples/react/realtime-trading/src/core/use-trading-table-runtime.ts new file mode 100644 index 0000000000..ece6b2bf55 --- /dev/null +++ b/examples/react/realtime-trading/src/core/use-trading-table-runtime.ts @@ -0,0 +1,40 @@ +import { shallow } from '@tanstack/react-store' +import { useTableBenchmark } from '../benchmark/use-table-benchmark' +import { + useTradingShellController, + useTradingShellState, +} from '../shell/trading-shell-context' + +export function useV9TradingTableRuntime(adapter: 'local') { + const controller = useTradingShellController() + const quotes = useTradingShellState((state) => state.displayQuotes) + const scrollStressMode = useTradingShellState( + (state) => state.scrollStressMode, + ) + + useTableBenchmark(controller, adapter, scrollStressMode) + + return { + quotes, + tableAtoms: controller.tableAtoms, + } +} + +export function useV8TradingTableRuntime() { + const controller = useTradingShellController() + const state = useTradingShellState( + (storeState) => ({ + quotes: storeState.displayQuotes, + coreRowModelMode: storeState.coreRowModelMode, + coreFilterValue: storeState.coreFilterValue, + }), + { compare: shallow }, + ) + const scrollStressMode = useTradingShellState( + (storeState) => storeState.scrollStressMode, + ) + + useTableBenchmark(controller, 'v8', scrollStressMode) + + return state +} diff --git a/examples/react/realtime-trading/src/index.css b/examples/react/realtime-trading/src/index.css new file mode 100644 index 0000000000..b3f25e0699 --- /dev/null +++ b/examples/react/realtime-trading/src/index.css @@ -0,0 +1,732 @@ +:root { + color-scheme: dark; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-synthesis: none; + --background: #090c11; + --panel: #10151c; + --panel-raised: #151b24; + --panel-hover: #19212b; + --border: #28313d; + --border-soft: #1d2530; + --text: #d7dde5; + --text-strong: #f1f4f8; + --muted: #7f8a98; + --blue: #4f8cff; + --blue-soft: #8cb5ff; + --green: #42c98a; + --red: #ef6a78; + --amber: #e8b95f; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-width: 320px; + min-height: 100%; + margin: 0; + background: var(--background); +} + +body { + color: var(--text); +} + +button, +select, +input { + font: inherit; +} + +button, +select { + color: var(--text); + background: var(--panel-raised); + border: 1px solid #394452; + border-radius: 2px; +} + +button { + padding: 0.5rem 0.65rem; + cursor: pointer; + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.025em; +} + +button:hover { + background: #1c2530; + border-color: #536171; +} + +button:focus-visible, +select:focus-visible, +input:focus-visible { + outline: 1px solid var(--blue); + outline-offset: 1px; +} + +select { + width: 100%; + padding: 0.45rem 0.55rem; + font-size: 0.75rem; +} + +input[type='text'] { + width: 100%; + padding: 0.45rem 0.55rem; + color: var(--text); + background: var(--panel-raised); + border: 1px solid #394452; + border-radius: 2px; + font-size: 0.75rem; +} + +.trading-terminal { + display: flex; + flex-direction: column; + width: 100%; + height: 100vh; + min-height: 640px; + overflow: hidden; + background: var(--background); +} + +.app-bar { + display: flex; + flex: 0 0 42px; + align-items: center; + justify-content: space-between; + padding: 0 0.75rem; + background: #0d1117; + border-bottom: 1px solid var(--border); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.68rem; + letter-spacing: 0.045em; +} + +.brand, +.session-info, +.feed-status { + display: flex; + align-items: center; +} + +.brand { + gap: 0.65rem; + color: var(--text-strong); +} + +.brand-mark { + display: grid; + width: 24px; + height: 24px; + place-items: center; + color: #fff; + background: var(--blue); + font-size: 0.62rem; + font-weight: 800; +} + +.environment { + padding: 0.16rem 0.3rem; + color: var(--amber); + background: rgb(232 185 95 / 8%); + border: 1px solid rgb(232 185 95 / 40%); + font-size: 0.58rem; +} + +.session-info { + gap: 1rem; + color: var(--muted); +} + +.feed-status { + gap: 0.4rem; + color: #9aa4b1; +} + +.feed-status.is-running { + color: var(--green); +} + +.status-dot { + width: 6px; + height: 6px; + background: #66717d; + border-radius: 50%; +} + +.feed-status.is-running .status-dot { + background: var(--green); +} + +.development-warning { + flex: 0 0 auto; + padding: 0.3rem 0.75rem; + color: #e9c67e; + background: #211b11; + border-bottom: 1px solid #4a3d25; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.62rem; + letter-spacing: 0.04em; +} + +.workspace { + display: grid; + flex: 1; + grid-template-columns: minmax(0, 1fr) 288px; + min-height: 0; +} + +.market-panel { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + background: var(--panel); + border-right: 1px solid var(--border); +} + +.market-toolbar { + display: flex; + flex: 0 0 42px; + align-items: center; + justify-content: space-between; + padding: 0 0.75rem; + background: #11171f; + border-bottom: 1px solid var(--border); +} + +.watchlist-name { + display: flex; + gap: 0.6rem; + align-items: baseline; + font-size: 0.67rem; +} + +.watchlist-name span { + color: var(--muted); +} + +.watchlist-name strong { + color: var(--text-strong); + font-size: 0.72rem; + letter-spacing: 0.035em; +} + +.market-context { + display: flex; + gap: 1rem; + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.6rem; +} + +.metrics-strip { + display: grid; + flex: 0 0 64px; + grid-template-columns: repeat(7, minmax(0, 1fr)); + background: var(--border); + border-bottom: 1px solid var(--border); + gap: 1px; +} + +.metrics-strip article { + min-width: 0; + padding: 0.55rem 0.65rem; + background: #0e131a; +} + +.metrics-strip span, +.metrics-strip small { + display: block; + overflow: hidden; + color: var(--muted); + font-size: 0.56rem; + letter-spacing: 0.045em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.metrics-strip strong { + display: block; + margin: 0.22rem 0 0.12rem; + overflow: hidden; + color: var(--text-strong); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.88rem; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; +} + +.metrics-strip strong.metric-alert { + color: var(--amber); +} + +.table-scroll { + flex: 1; + min-height: 0; + overflow: auto; + background: #0d1218; +} + +app-current-trading-table, +app-v8-trading-table { + display: flex; + flex: 1; + min-width: 0; + min-height: 0; +} + +table { + min-width: 100%; + border-spacing: 0; + table-layout: fixed; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; + font-variant-numeric: tabular-nums; +} + +thead { + position: sticky; + z-index: 2; + top: 0; +} + +th { + height: 28px; + padding: 0 0.6rem; + color: #8e99a6; + background: #171e27; + border-right: 1px solid #222b36; + border-bottom: 1px solid #394452; + font-size: 0.58rem; + font-weight: 700; + letter-spacing: 0.055em; + text-align: left; + text-transform: uppercase; +} + +td { + height: 27px; + padding: 0 0.6rem; + overflow: hidden; + color: #cbd2db; + border-right: 1px solid #1a222c; + border-bottom: 1px solid #1b232d; + text-overflow: ellipsis; + white-space: nowrap; +} + +th:last-child, +td:last-child { + border-right: 0; +} + +.numeric-cell { + text-align: right; +} + +tbody tr:nth-child(even) { + background: rgb(255 255 255 / 1.3%); +} + +tbody tr:hover { + background: #17202a; +} + +tbody tr.is-selected { + background: rgb(79 140 255 / 13%); + box-shadow: inset 2px 0 0 var(--blue); +} + +.price-button { + min-width: 4.5rem; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-variant-numeric: tabular-nums; + font-weight: 600; + text-align: right; +} + +.price-button:hover { + background: transparent; + text-decoration: underline; + text-underline-offset: 2px; +} + +.move-cell { + display: inline-block; + min-width: 4.8rem; +} + +.spread-cell { + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: 0.35rem; + color: #cbd2db; +} + +.spread-cell small { + min-width: 2.8rem; + color: var(--muted); + font-size: 0.55rem; +} + +.spread-cell.spread-wide, +.spread-cell.spread-wide small { + color: var(--amber); +} + +app-depth-cell { + display: block; + width: 100%; +} + +.depth-cell { + position: relative; + display: flex; + height: 17px; + overflow: hidden; + background: #151b23; +} + +.depth-bid, +.depth-ask { + height: 100%; + opacity: 0.28; +} + +.depth-bid { + background: var(--blue); + border-right: 1px solid #10151c; +} + +.depth-ask { + background: var(--red); +} + +.depth-values { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.28rem; + color: #dce2e9; + font-size: 0.57rem; + text-shadow: 0 1px #080b0f; +} + +.quote-age { + color: #aab3bf; +} + +.quote-age-warm { + color: var(--amber); +} + +.quote-age-stale { + color: var(--red); +} + +.quote-up { + color: var(--green); +} + +.quote-down { + color: var(--red); +} + +.sparkline { + display: block; + width: 8rem; + height: 1.2rem; + margin-left: auto; + overflow: visible; +} + +.sparkline polyline { + fill: none; + stroke: var(--blue-soft); + stroke-linecap: square; + stroke-linejoin: miter; + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} + +.market-statusbar { + display: flex; + flex: 0 0 25px; + gap: 1.1rem; + align-items: center; + padding: 0 0.65rem; + color: var(--muted); + background: #0d1117; + border-top: 1px solid var(--border); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.56rem; + letter-spacing: 0.035em; +} + +.market-statusbar strong { + margin-left: 0.25rem; + color: #c4ccd6; + font-weight: 500; +} + +.statusbar-spacer { + flex: 1; +} + +.configurator { + min-height: 0; + overflow: auto; + background: #0d1218; +} + +.configurator > header { + display: flex; + height: 42px; + align-items: center; + justify-content: space-between; + padding: 0 0.75rem; + color: var(--text-strong); + background: #11171f; + border-bottom: 1px solid var(--border); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.055em; +} + +.configurator > header small { + color: var(--muted); + font-size: 0.54rem; + font-weight: 500; +} + +.config-section { + display: grid; + gap: 0.7rem; + padding: 0.8rem 0.75rem; + border-bottom: 1px solid var(--border); +} + +.config-section h2 { + margin: 0; + color: #8e99a6; + font-size: 0.58rem; + letter-spacing: 0.075em; +} + +.primary-action { + width: 100%; + color: #fff; + background: #316fdd; + border-color: #4f8cff; +} + +.primary-action:hover { + background: #397bed; + border-color: #73a2ff; +} + +.field { + display: grid; + gap: 0.35rem; + color: #9ba5b1; + font-size: 0.66rem; +} + +.rate-field > span { + display: flex; + justify-content: space-between; +} + +.rate-field strong { + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-weight: 500; +} + +.field small { + color: #66717e; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.55rem; +} + +input[type='range'] { + width: 100%; + margin: 0; + accent-color: var(--blue); +} + +.toggle-field { + display: flex; + gap: 0.5rem; + align-items: flex-start; + color: #b8c0ca; + font-size: 0.66rem; + line-height: 1.3; +} + +.toggle-field input { + margin: 0.12rem 0 0; + accent-color: var(--blue); +} + +.toggle-field small { + display: block; + margin-top: 0.14rem; + color: #687482; + font-size: 0.57rem; +} + +.action-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.4rem; +} + +.diagnostics dl, +.selected-instrument dl { + display: grid; + gap: 0; + margin: 0; +} + +.diagnostics dl > div, +.selected-instrument dl > div { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 24px; + border-bottom: 1px solid var(--border-soft); +} + +.diagnostics dl > div:last-child, +.selected-instrument dl > div:last-child { + border-bottom: 0; +} + +dt { + color: var(--muted); + font-size: 0.61rem; +} + +dd { + margin: 0; + color: #d4dae2; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.62rem; +} + +.selection { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.selection div { + display: grid; + gap: 0.18rem; +} + +.selection strong { + color: var(--text-strong); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85rem; +} + +.selection span, +.selection small, +.selected-instrument p { + color: var(--muted); + font-size: 0.61rem; +} + +.selected-instrument p { + margin: 0; + line-height: 1.45; +} + +@media (max-width: 980px) { + .workspace { + grid-template-columns: minmax(0, 1fr) 250px; + } + + .market-context span:not(:first-child) { + display: none; + } + + .metrics-strip { + grid-template-columns: repeat(3, 1fr); + flex-basis: 166px; + } +} + +@media (max-width: 720px) { + html, + body { + min-height: 100%; + } + + body { + overflow: auto; + } + + .trading-terminal { + height: auto; + min-height: 100vh; + overflow: visible; + } + + .session-info > span:first-child { + display: none; + } + + .workspace { + display: flex; + flex-direction: column; + } + + .market-panel { + min-height: 68vh; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .metrics-strip { + grid-template-columns: repeat(2, 1fr); + flex-basis: 220px; + } + + .table-scroll { + height: 58vh; + flex: none; + } + + .market-context { + display: none; + } + + .configurator { + overflow: visible; + } +} diff --git a/examples/react/realtime-trading/src/main.tsx b/examples/react/realtime-trading/src/main.tsx new file mode 100644 index 0000000000..f3a5967836 --- /dev/null +++ b/examples/react/realtime-trading/src/main.tsx @@ -0,0 +1,10 @@ +import { createRoot } from 'react-dom/client' +import { App } from './App' +import './index.css' + +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Failed to find the root element') + +// StrictMode is intentionally omitted: its development-only mount replay would +// contaminate the component lifecycle counters used by this benchmark. +createRoot(rootElement).render() diff --git a/examples/react/realtime-trading/src/market-data.ts b/examples/react/realtime-trading/src/market-data.ts new file mode 100644 index 0000000000..02eefa28c3 --- /dev/null +++ b/examples/react/realtime-trading/src/market-data.ts @@ -0,0 +1,38 @@ +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './market-feed-protocol' + +export interface MarketQuote extends Omit { + history: ReadonlyArray +} + +export function hydrateMarketQuotes( + snapshots: Array, +): Array { + return snapshots.map((quote) => ({ + ...quote, + history: [...quote.history], + })) +} + +export function applyMarketUpdates( + quotes: Array, + updates: Array, +): Array { + const nextQuotes = [...quotes] + + for (const update of updates) { + const { index, history, ...values } = update + const previousQuote = quotes.at(index) + if (!previousQuote) continue + + nextQuotes[index] = { + ...previousQuote, + ...values, + history: history ?? previousQuote.history, + } + } + + return nextQuotes +} diff --git a/examples/react/realtime-trading/src/market-feed-engine.ts b/examples/react/realtime-trading/src/market-feed-engine.ts new file mode 100644 index 0000000000..e77d76656c --- /dev/null +++ b/examples/react/realtime-trading/src/market-feed-engine.ts @@ -0,0 +1,173 @@ +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const baseInstruments = [ + ['ALP', 'Alpine Systems', 'XNAS'], + ['ARC', 'Arcadia Cloud', 'XNYS'], + ['BLU', 'Blue River Energy', 'BATS'], + ['CRN', 'Crown Robotics', 'XNAS'], + ['DYN', 'Dynasty Networks', 'XNYS'], + ['ECO', 'Ecoframe Materials', 'IEX'], + ['FLX', 'Flux Semiconductors', 'XNAS'], + ['GEO', 'Geode Analytics', 'BATS'], + ['HLX', 'Helix Biotech', 'XNYS'], + ['ION', 'Ion Mobility', 'IEX'], + ['JDE', 'Jade Financial', 'XNYS'], + ['KNT', 'Kinetic Aerospace', 'XNAS'], +] as const + +export class MarketFeedEngine { + #quotes: Array = [] + #random = createRandom(2_026) + #rowCursor = 0 + #historyTick = 0 + #eventIndex = 0 + + reset(count: number, seed: number): Array { + const random = createRandom(seed) + + this.#quotes = Array.from({ length: count }, (_, index) => { + const [baseSymbol, company, venue] = + baseInstruments[index % baseInstruments.length] + const series = Math.floor(index / baseInstruments.length) + const symbol = series === 0 ? baseSymbol : `${baseSymbol}${series}` + const open = roundPrice(20 + random() * 480) + const spread = Math.max(0.01, open * (0.0002 + random() * 0.0004)) + const history = Array.from({ length: 24 }, (_, historyIndex) => + roundPrice( + open * + (1 + Math.sin(historyIndex / 4) * 0.002 + (random() - 0.5) * 0.001), + ), + ) + const volume = Math.floor(50_000 + random() * 2_000_000) + const lastSize = Math.floor(10 + random() * 5_000) + + return { + id: `instrument-${index}`, + symbol, + company, + venue, + open, + price: open, + bid: roundPrice(open - spread / 2), + ask: roundPrice(open + spread / 2), + bidSize: Math.floor(100 + random() * 25_000), + askSize: Math.floor(100 + random() * 25_000), + lastSize, + lastMove: 0, + lastUpdatedAt: Date.now(), + volume, + turnover: roundMoney(open * volume), + history, + } + }) + + this.#random = createRandom(2_026 + seed) + this.#rowCursor = 0 + this.#historyTick = 0 + + return this.#quotes.map((quote) => ({ + ...quote, + history: [...quote.history], + })) + } + + applyEvents( + eventCount: number, + updateSparklines: boolean, + ): Array { + if (this.#quotes.length === 0 || eventCount <= 0) return [] + + const updatedAt = Date.now() + const updatedQuotes = new Map() + const stride = 97 + + this.#eventIndex = 0 + while (this.#eventIndex < eventCount) { + this.#rowCursor = (this.#rowCursor + stride) % this.#quotes.length + const quote = this.#quotes[this.#rowCursor] + const shouldUpdateHistory = + updateSparklines && this.#historyTick++ % 4 === 0 + + this.#applyTick(quote, shouldUpdateHistory, updatedAt) + + const previousUpdate = updatedQuotes.get(this.#rowCursor) + updatedQuotes.set(this.#rowCursor, { + index: this.#rowCursor, + price: quote.price, + bid: quote.bid, + ask: quote.ask, + bidSize: quote.bidSize, + askSize: quote.askSize, + lastSize: quote.lastSize, + lastMove: quote.lastMove, + lastUpdatedAt: quote.lastUpdatedAt, + volume: quote.volume, + turnover: quote.turnover, + ...(shouldUpdateHistory || previousUpdate?.history + ? { history: [...quote.history] } + : {}), + }) + this.#eventIndex++ + } + + return [...updatedQuotes.values()] + } + + #applyTick( + quote: MarketQuoteSnapshot, + updateHistory: boolean, + updatedAt: number, + ): void { + const previousPrice = quote.price + const volatility = 0.00015 + this.#random() * 0.0012 + const move = previousPrice * (this.#random() - 0.495) * volatility + const nextPrice = roundPrice(Math.max(0.1, previousPrice + move)) + const spread = Math.max( + 0.01, + nextPrice * (0.00015 + this.#random() * 0.0005), + ) + + quote.lastMove = nextPrice - previousPrice + quote.price = nextPrice + quote.bid = roundPrice(nextPrice - spread / 2) + quote.ask = roundPrice(nextPrice + spread / 2) + quote.bidSize = Math.floor(100 + this.#random() * 25_000) + quote.askSize = Math.floor(100 + this.#random() * 25_000) + quote.lastSize = Math.floor(10 + this.#random() * 5_000) + quote.lastUpdatedAt = updatedAt + quote.volume += quote.lastSize + quote.turnover = roundMoney(quote.turnover + nextPrice * quote.lastSize) + + if (updateHistory) { + quote.history = [...quote.history.slice(-23), nextPrice] + } + } +} + +function createRandom(seed: number): () => number { + const runtime = { state: seed >>> 0 } + return () => { + runtime.state += 0x6d2b79f5 + const stateValue = runtime.state + const firstMix = Math.imul( + stateValue ^ (stateValue >>> 15), + stateValue | 1, + ) + const secondMix = + firstMix + + Math.imul(firstMix ^ (firstMix >>> 7), firstMix | 61) + const value = firstMix ^ secondMix + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296 + } +} + +function roundPrice(value: number): number { + return Math.round(value * 100) / 100 +} + +function roundMoney(value: number): number { + return Math.round(value * 100) / 100 +} diff --git a/examples/react/realtime-trading/src/market-feed-protocol.ts b/examples/react/realtime-trading/src/market-feed-protocol.ts new file mode 100644 index 0000000000..0a8b9925f5 --- /dev/null +++ b/examples/react/realtime-trading/src/market-feed-protocol.ts @@ -0,0 +1,66 @@ +export interface MarketQuoteSnapshot { + id: string + symbol: string + company: string + venue: string + open: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + volume: number + turnover: number + history: Array +} + +export interface MarketQuoteUpdate { + index: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + volume: number + turnover: number + history?: Array +} + +export type MarketFeedCommand = + | { + type: 'initialize' + rowCount: number + seed: number + running: boolean + targetEventsPerSecond: number + updateSparklines: boolean + } + | { + type: 'configure' + running?: boolean + targetEventsPerSecond?: number + updateSparklines?: boolean + } + | { type: 'reset'; rowCount: number; seed: number } + | { type: 'burst'; eventCount: number } + | { type: 'ack'; generation: number; sequence: number } + +export type MarketFeedEvent = + | { + type: 'ready' + generation: number + quotes: Array + } + | { + type: 'batch' + generation: number + sequence: number + eventCount: number + updates: Array + } diff --git a/examples/react/realtime-trading/src/market-feed.worker.ts b/examples/react/realtime-trading/src/market-feed.worker.ts new file mode 100644 index 0000000000..7ce6313798 --- /dev/null +++ b/examples/react/realtime-trading/src/market-feed.worker.ts @@ -0,0 +1,133 @@ +import { MarketFeedEngine } from './market-feed-engine' +import type { + MarketFeedCommand, + MarketFeedEvent, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const engine = new MarketFeedEngine() +const pendingUpdates = new Map() + +const runtime = { + generation: 0, + sequence: 0, + inFlightSequence: null as number | null, + pendingEventCount: 0, + initialized: false, + running: true, + targetEventsPerSecond: 10_000, + updateSparklines: true, + eventBudget: 0, + lastTickAt: performance.now(), +} + +addEventListener('message', ({ data }: MessageEvent) => { + switch (data.type) { + case 'initialize': + runtime.running = data.running + runtime.targetEventsPerSecond = data.targetEventsPerSecond + runtime.updateSparklines = data.updateSparklines + reset(data.rowCount, data.seed) + break + case 'configure': + runtime.running = data.running ?? runtime.running + runtime.targetEventsPerSecond = + data.targetEventsPerSecond ?? runtime.targetEventsPerSecond + runtime.updateSparklines = + data.updateSparklines ?? runtime.updateSparklines + break + case 'reset': + reset(data.rowCount, data.seed) + break + case 'burst': + produceEvents(data.eventCount) + flush() + break + case 'ack': + if ( + data.generation === runtime.generation && + data.sequence === runtime.inFlightSequence + ) { + runtime.inFlightSequence = null + flush() + } + break + } +}) + +setInterval(() => { + const now = performance.now() + const elapsed = Math.min(100, Math.max(0, now - runtime.lastTickAt)) + runtime.lastTickAt = now + + if (runtime.initialized && runtime.running) { + runtime.eventBudget += + (runtime.targetEventsPerSecond * elapsed) / 1_000 + const eventCount = Math.floor(runtime.eventBudget) + runtime.eventBudget -= eventCount + produceEvents(eventCount) + } + + flush() +}, 16) + +function reset(rowCount: number, seed: number): void { + runtime.initialized = true + runtime.generation++ + runtime.sequence = 0 + runtime.inFlightSequence = null + runtime.pendingEventCount = 0 + pendingUpdates.clear() + runtime.eventBudget = 0 + runtime.lastTickAt = performance.now() + + post({ + type: 'ready', + generation: runtime.generation, + quotes: engine.reset(rowCount, seed), + }) +} + +function produceEvents(eventCount: number): void { + if (!runtime.initialized || eventCount <= 0) return + + runtime.pendingEventCount += eventCount + for (const update of engine.applyEvents( + eventCount, + runtime.updateSparklines, + )) { + const previousUpdate = pendingUpdates.get(update.index) + pendingUpdates.set(update.index, { + ...update, + ...(update.history || !previousUpdate?.history + ? {} + : { history: previousUpdate.history }), + }) + } +} + +function flush(): void { + if ( + runtime.inFlightSequence !== null || + runtime.pendingEventCount === 0 + ) + return + + const nextSequence = ++runtime.sequence + const message: MarketFeedEvent = { + type: 'batch', + generation: runtime.generation, + sequence: nextSequence, + eventCount: runtime.pendingEventCount, + updates: [...pendingUpdates.values()], + } + + runtime.pendingEventCount = 0 + pendingUpdates.clear() + runtime.inFlightSequence = nextSequence + post(message) +} + +function post(event: MarketFeedEvent): void { + postMessage(event) +} diff --git a/examples/react/realtime-trading/src/quote-cells.tsx b/examples/react/realtime-trading/src/quote-cells.tsx new file mode 100644 index 0000000000..ab38df1ad8 --- /dev/null +++ b/examples/react/realtime-trading/src/quote-cells.tsx @@ -0,0 +1,190 @@ +import { useEffect } from 'react' + +const compactNumber = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +export const quoteCellLifecycle = { + created: 0, + destroyed: 0, +} + +export const quoteCellRendererNames = [ + 'Ticker', + 'Venue', + 'Bid', + 'Ask', + 'Spread', + 'Last', + 'LastMove', + 'LastQty', + 'Depth', + 'QuoteAge', + 'DayChange', + 'TotalQty', + 'TradedValue', + 'Intraday', +] as const + +export const quoteComponentNames = [ + 'PriceCell', + 'StableMoveCell', + 'UpMoveCell', + 'DownMoveCell', + 'SpreadCell', + 'DepthCell', + 'QuoteAgeCell', + 'SparklineCell', +] as const + +export type QuoteCellRendererName = (typeof quoteCellRendererNames)[number] +export type QuoteComponentName = (typeof quoteComponentNames)[number] + +const createCounterMap = ( + names: ReadonlyArray, +): Record => + Object.fromEntries(names.map((name) => [name, 0])) as Record + +export const quoteRenderDiagnostics = { + cellRendererCalls: 0, + componentRenderCalls: 0, + cellRendererCallsByName: createCounterMap(quoteCellRendererNames), + componentRenderCallsByName: createCounterMap(quoteComponentNames), +} + +export function recordCellRender( + name: QuoteCellRendererName, + value: T, +): T { + 'use no memo' + // Benchmark instrumentation is intentionally impure and must run per call. + quoteRenderDiagnostics.cellRendererCalls++ + quoteRenderDiagnostics.cellRendererCallsByName[name]++ + return value +} + +function useLifecycleCounter(componentName: QuoteComponentName): void { + 'use no memo' + // This diagnostic mutation measures component-function invocation itself. + quoteRenderDiagnostics.componentRenderCalls++ + quoteRenderDiagnostics.componentRenderCallsByName[componentName]++ + useEffect(() => { + quoteCellLifecycle.created++ + return () => { + quoteCellLifecycle.destroyed++ + } + }, []) +} + +export function PriceCell(props: { + price: number + move: number + onSelect: () => void +}) { + useLifecycleCounter('PriceCell') + return ( + + ) +} + +export function StableMoveCell({ move }: { move: number }) { + useLifecycleCounter('StableMoveCell') + return ( + = 0 ? 'quote-up' : 'quote-down'}`}> + {formatSigned(move)} + + ) +} + +export function UpMoveCell({ move }: { move: number }) { + useLifecycleCounter('UpMoveCell') + return โ–ฒ {formatSigned(move)} +} + +export function DownMoveCell({ move }: { move: number }) { + useLifecycleCounter('DownMoveCell') + return โ–ผ {formatSigned(move)} +} + +export function SpreadCell({ bid, ask }: { bid: number; ask: number }) { + useLifecycleCounter('SpreadCell') + const spread = Math.max(0, ask - bid) + const midpoint = (bid + ask) / 2 + const basisPoints = midpoint === 0 ? 0 : (spread / midpoint) * 10_000 + + return ( + = 4 ? 'spread-wide' : ''}`}> + {spread.toFixed(2)} + {basisPoints.toFixed(1)} bp + + ) +} + +export function DepthCell(props: { bidSize: number; askSize: number }) { + useLifecycleCounter('DepthCell') + const total = props.bidSize + props.askSize + const bidShare = total === 0 ? 50 : (props.bidSize / total) * 100 + + return ( +
+ + + + {compactNumber.format(props.bidSize)} + {compactNumber.format(props.askSize)} + +
+ ) +} + +export function QuoteAgeCell({ ageMs }: { ageMs: number }) { + useLifecycleCounter('QuoteAgeCell') + const className = + ageMs >= 1_500 + ? 'quote-age quote-age-stale' + : ageMs >= 500 + ? 'quote-age quote-age-warm' + : 'quote-age' + + return ( + + {ageMs < 1_000 + ? `${Math.round(ageMs)} ms` + : `${(ageMs / 1_000).toFixed(1)} s`} + + ) +} + +export function SparklineCell({ values }: { values: ReadonlyArray }) { + useLifecycleCounter('SparklineCell') + const min = Math.min(...values) + const max = Math.max(...values) + const range = max - min || 1 + const denominator = Math.max(1, values.length - 1) + const points = values + .map((value, index) => { + const x = (index / denominator) * 100 + const y = 22 - ((value - min) / range) * 20 + return `${x.toFixed(1)},${y.toFixed(1)}` + }) + .join(' ') + + return ( + + + + ) +} + +function formatSigned(value: number): string { + return `${value >= 0 ? '+' : ''}${value.toFixed(2)}` +} diff --git a/examples/react/realtime-trading/src/shell/TradingShell.tsx b/examples/react/realtime-trading/src/shell/TradingShell.tsx new file mode 100644 index 0000000000..3e2aa38fe4 --- /dev/null +++ b/examples/react/realtime-trading/src/shell/TradingShell.tsx @@ -0,0 +1,775 @@ +import { shallow } from '@tanstack/react-store' +import { + useTradingShellController, + useTradingShellState, +} from './trading-shell-context' +import type { ReactNode } from 'react' +import type { + FeedMetrics, + ScrollStressMode, +} from '../benchmark/benchmark-monitor' +import type { FeedLoadProfile, RowWorkloadMode } from '../benchmark-profiles' +import type { + CoreRowModelMode, + TableAdapter, +} from '../trading-table' + +const integerFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 0, +}) +const rateFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) +export function TradingShell(props: { children: ReactNode }) { + return ( +
+ + + {import.meta.env.DEV && ( + + )} + + {import.meta.env.MODE === 'production' && ( + + )} + +
+
+ + + {props.children} + +
+ +
+
+ ) +} + +function AppHeader() { + const { workerReady, running } = useTradingShellState( + (state) => ({ + workerReady: state.workerReady, + running: state.running, + }), + { compare: shallow }, + ) + return ( +
+
+ TT + MARKET MONITOR + SIMULATED +
+
+ REACT / FLEX RENDER + + +
+
+ ) +} + +function MarketToolbar() { + const { + coreRowModelMode, + rendererMode, + updateSparklines, + updateQuoteAges, + quoteCount, + displayQuoteCount, + adapterLabel, + workloadLabel, + } = useTradingShellState( + (state) => ({ + coreRowModelMode: state.coreRowModelMode, + rendererMode: state.rendererMode, + updateSparklines: state.updateSparklines, + updateQuoteAges: state.updateQuoteAges, + quoteCount: state.quotes.length, + displayQuoteCount: state.displayQuotes.length, + adapterLabel: state.adapterLabel, + workloadLabel: state.workloadLabel, + }), + { compare: shallow }, + ) + return ( +
+
+ WATCHLIST + ALL INSTRUMENTS +
+
+ + {formatInteger(displayQuoteCount)} / {formatInteger(quoteCount)}{' '} + SYMBOLS + + REACT {adapterLabel} + WORKER STREAM + IMMUTABLE ROWS + {workloadLabel} + + {coreRowModelMode === 'none' + ? 'CORE ROW MODEL OFF' + : `CORE ${coreRowModelMode.toUpperCase()}`} + + + {rendererMode === 'stable' ? 'STABLE CELLS' : 'A/B CELL SWAP'} + + {updateSparklines ? 'CHARTS ON' : 'CHARTS OFF'} + {updateQuoteAges ? 'AGE CLOCK ON' : 'AGE CLOCK OFF'} +
+
+ ) +} + +function MarketStatusbar() { + const { lastBatchSize, lastUpdateCount, mountedCells, liveComponents } = + useTradingShellState( + (state) => ({ + lastBatchSize: state.metrics.lastBatchSize, + lastUpdateCount: state.metrics.lastUpdateCount, + mountedCells: state.mountedCells, + liveComponents: state.liveComponents, + }), + { compare: shallow }, + ) + return ( +
+ + BATCH EVENTS {formatInteger(lastBatchSize)} + + + ROW UPDATES {formatInteger(lastUpdateCount)} + + + HOSTS {formatInteger(mountedCells)} + + + COMPONENTS {formatInteger(liveComponents)} + + + WORKER / ACKNOWLEDGED / IMMUTABLE +
+ ) +} + +function Configurator() { + const state = useTradingShellState( + (storeState) => ({ + running: storeState.running, + tableAdapter: storeState.tableAdapter, + instrumentCount: storeState.instrumentCount, + feedLoadProfile: storeState.feedLoadProfile, + targetEventsPerSecond: storeState.targetEventsPerSecond, + rowWorkloadMode: storeState.rowWorkloadMode, + coreRowModelMode: storeState.coreRowModelMode, + coreFilterValue: storeState.coreFilterValue, + scrollStressMode: storeState.scrollStressMode, + rendererMode: storeState.rendererMode, + updateSparklines: storeState.updateSparklines, + updateQuoteAges: storeState.updateQuoteAges, + }), + { compare: shallow }, + ) + const { actions } = useTradingShellController() + const { + running, + tableAdapter, + instrumentCount, + feedLoadProfile, + targetEventsPerSecond, + rowWorkloadMode, + coreRowModelMode, + coreFilterValue, + scrollStressMode, + rendererMode, + updateSparklines, + updateQuoteAges, + } = state + const { + toggleFeed, + setInstrumentCount, + setFeedLoadProfile, + setTargetEventsPerSecond, + setRowWorkloadMode, + setTableAdapter, + setCoreRowModelMode, + setCoreFilterValue, + setScrollStressMode, + setRendererMode, + setUpdateSparklines, + setUpdateQuoteAges, + runBurst, + resetMarket, + } = actions + + return ( + + ) +} + +function MetricsStrip() { + const { metrics, longAnimationFramesSupported } = + useTradingShellState( + (state) => ({ + metrics: state.metrics, + longAnimationFramesSupported: state.longAnimationFramesSupported, + }), + { compare: shallow }, + ) + return ( +
+
+ THROUGHPUT + + {formatRate(metrics.actualEventsPerSecond)} + + events/s +
+
+ RAF RATE + + {metrics.rafCallbacksPerSecond.toFixed(1)} + + callbacks/s +
+
+ TABLE RENDERS + + {metrics.tableRendersPerSecond.toFixed(1)} + + worker batches/s +
+
+ AVG RENDER + {formatMs(metrics.averageRenderMs)} + mutation โ†’ render +
+
+ P95 RENDER + {formatMs(metrics.p95RenderMs)} + max {formatMs(metrics.maxRenderMs)} +
+
+ LONG FRAMES + {longAnimationFramesSupported ? ( + <> + 0 ? 'metric-alert' : ''} + > + {metrics.longAnimationFrames} + + worst {formatMs(metrics.worstLongAnimationFrameMs)} + + ) : ( + <> + N/A + unsupported + + )} +
+
+ TOTAL EVENTS + + {formatInteger(metrics.totalEvents)} + + since reset +
+
+ ) +} + +function Diagnostics() { + const { + metrics, + mountedCells, + liveComponents, + longAnimationFramesSupported, + } = useTradingShellState( + (state) => ({ + metrics: state.metrics, + mountedCells: state.mountedCells, + liveComponents: state.liveComponents, + longAnimationFramesSupported: state.longAnimationFramesSupported, + }), + { compare: shallow }, + ) + const profilerEnabled = + import.meta.env.DEV || import.meta.env.MODE === 'profile' + return ( +
+

DIAGNOSTICS

+
+
+
Mounted cells
+
{formatInteger(mountedCells)}
+
+
+
Live components
+
{formatInteger(liveComponents)}
+
+
+
Created / destroyed
+
+ {formatInteger(metrics.componentsCreated)} /{' '} + {formatInteger(metrics.componentsDestroyed)} +
+
+
+
Cell renderer calls / s
+
+ {formatRate(metrics.cellRendererCallsPerSecond)} +
+
+
+
Component function calls / s
+
+ {formatRate(metrics.componentRenderCallsPerSecond)} +
+
+
+
Component function calls by type / s
+
+ {formatInvocationRates(metrics.componentRenderRates)} +
+
+
+
Cell callbacks by column / s
+
+ {formatInvocationRates(metrics.cellRendererRates)} +
+
+
+
DOM mutation records / s
+
+ {formatRate(metrics.domMutationsPerSecond)} +
+
+
+
React Profiler commits / s
+
+ {profilerEnabled + ? metrics.profilerCommitsPerSecond.toFixed(1) + : 'PROFILE BUILD REQUIRED'} +
+
+
+
Profiler actual avg / p95
+
+ {profilerEnabled + ? `${formatMs(metrics.profilerAverageActualMs)} / ${formatMs( + metrics.profilerP95ActualMs, + )}` + : 'N/A'} +
+
+
+
Profiler base avg
+
+ {profilerEnabled + ? formatMs(metrics.profilerAverageBaseMs) + : 'N/A'} +
+
+
+
Core row model calls / s
+
+ {metrics.rowModelCallsPerSecond.toFixed(1)} +
+
+
+
Core row model avg / max
+
+ {formatMs(metrics.rowModelAverageMs)} /{' '} + {formatMs(metrics.rowModelMaxMs)} +
+
+
+
Visible rows
+
+ {formatInteger(metrics.visibleRows)} +
+
+
+
Scroll callbacks / distance
+
+ {metrics.scrollCallbacksPerSecond.toFixed(1)} /{' '} + {formatRate(metrics.scrollDistancePerSecond)} px/s +
+
+
+
Delayed scroll frames (>34 ms)
+
+ {formatInteger(metrics.scrollJankFrames)} +
+
+
+
Worker messages
+
+ {formatInteger(metrics.workerMessages)} +
+
+
+
Last batch events / rows
+
+ {formatInteger(metrics.lastBatchSize)} /{' '} + {formatInteger(metrics.lastUpdateCount)} +
+
+
+
Renders > 16.7 ms
+
{metrics.slowRenders}
+
+
+
Long animation frames
+
+ {longAnimationFramesSupported + ? formatInteger(metrics.longAnimationFrames) + : 'Unsupported'} +
+
+
+
JS heap
+
+ {metrics.heapMb === null + ? 'N/A' + : `${metrics.heapMb.toFixed(1)} MB`} +
+
+
+
+ ) +} + +function SelectedInstrument() { + const selectedQuote = useTradingShellState( + (state) => state.selectedQuote, + ) + return ( +
+

SELECTED INSTRUMENT

+ {selectedQuote ? ( + <> +
+
+ {selectedQuote.symbol} + {selectedQuote.company} +
+ {selectedQuote.venue} +
+
+
+
Last
+
{selectedQuote.price.toFixed(2)}
+
+
+
Bid / ask
+
+ {selectedQuote.bid.toFixed(2)} /{' '} + {selectedQuote.ask.toFixed(2)} +
+
+
+ + ) : ( +

Click a value in the Last column to inspect its output.

+ )} +
+ ) +} + +function formatInteger(value: number): string { + return integerFormatter.format(value) +} + +function formatRate(value: number): string { + return rateFormatter.format(value) +} + +function formatMs(value: number): string { + return `${value.toFixed(2)} ms` +} + +function formatInvocationRates( + rates: FeedMetrics['componentRenderRates'], +): string { + const activeRates = rates + .filter((rate) => rate.callsPerSecond > 0) + .sort((left, right) => right.callsPerSecond - left.callsPerSecond) + return activeRates.length === 0 + ? 'No calls in sample' + : activeRates + .map( + (rate) => `${rate.name} ${formatRate(rate.callsPerSecond)}`, + ) + .join(' ยท ') +} diff --git a/examples/react/realtime-trading/src/shell/trading-shell-context.tsx b/examples/react/realtime-trading/src/shell/trading-shell-context.tsx new file mode 100644 index 0000000000..90af58c01c --- /dev/null +++ b/examples/react/realtime-trading/src/shell/trading-shell-context.tsx @@ -0,0 +1,33 @@ +import { createStoreContext, useSelector } from '@tanstack/react-store' +import type { ReactNode } from 'react' +import type { TradingBenchmarkController } from '../core/use-trading-benchmark-controller' +import type { + TradingBenchmarkState, +} from '../core/trading-benchmark-controller' +import type { UseSelectorOptions } from '@tanstack/react-store' + +const { + StoreProvider: TradingStoreProvider, + useStoreContext: useTradingShellController, +} = createStoreContext() + +export function TradingShellProvider(props: { + controller: TradingBenchmarkController + children: ReactNode +}) { + return ( + + {props.children} + + ) +} + +export { useTradingShellController } + +export function useTradingShellState( + selector: (state: TradingBenchmarkState) => TSelected, + options?: UseSelectorOptions, +): TSelected { + const controller = useTradingShellController() + return useSelector(controller.store, selector, options) +} diff --git a/examples/react/realtime-trading/src/trading-table-local.tsx b/examples/react/realtime-trading/src/trading-table-local.tsx new file mode 100644 index 0000000000..a732ead873 --- /dev/null +++ b/examples/react/realtime-trading/src/trading-table-local.tsx @@ -0,0 +1,144 @@ +import { + FlexRender, + createFilteredRowModel, + createSortedRowModel, + filterFn_includesString, + sortFn_basic, + stockFeatures, + tableFeatures, + useTable, +} from '@tanstack/react-table' +import { + TradingRow, + readMeasuredRows, + tradingColumns, +} from './trading-table-shared' +import { useTableBenchmark } from './benchmark/use-table-benchmark' +import { + useTradingShellController, + useTradingShellState, +} from './shell/trading-shell-context' +import type { MarketQuote } from './market-data' +import type { CoreTableState } from './trading-table-shared' + +const localFeatures = tableFeatures({ + ...stockFeatures, + filteredRowModel: createFilteredRowModel(), + sortedRowModel: createSortedRowModel(), + filterFns: { includesString: filterFn_includesString }, + sortFns: { basic: sortFn_basic }, +}) + +export function LocalTradingTable() { + const controller = useTradingShellController() + const quotes = useTradingShellState((state) => state.displayQuotes) + const scrollStressMode = useTradingShellState( + (state) => state.scrollStressMode, + ) + + useTableBenchmark(controller, 'local', scrollStressMode) + const table = useLocalTradingTable({ + quotes, + tableAtoms: controller.tableAtoms, + }) + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + +
+ {!header.isPlaceholder && } +
+
+ ) +} + +function useLocalTradingTable(props: { + quotes: Array + tableAtoms: ReturnType['tableAtoms'] +}) { + return useTable( + { + key: 'react-realtime-trading-local', + features: localFeatures, + columns: tradingColumns, + data: props.quotes, + getRowId: (row) => row.id, + atoms: props.tableAtoms, + }, + () => null, + ) +} + +function LocalTradingTableBody(props: { + table: ReturnType + quotes: Array +}) { + return ( + ({ + sorting: state.sorting, + columnFilters: state.columnFilters, + })} + > + {(coreState) => ( + + )} + + ) +} + +function LocalTradingRows(props: { + table: ReturnType + quoteSnapshot: Array + coreState: CoreTableState +}) { + const rows = readLocalRows(props.table, props.quoteSnapshot, props.coreState) + + return ( + + {rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + + + ))} + + ))} + + ) +} + +function readLocalRows( + table: ReturnType, + quoteSnapshot: Array, + coreState: CoreTableState, +) { + // These explicit inputs describe the external dependencies behind the + // table's mutable row-model API to React Compiler. + void quoteSnapshot + void coreState + return readMeasuredRows('local', () => table.getRowModel().rows) +} diff --git a/examples/react/realtime-trading/src/trading-table-shared.tsx b/examples/react/realtime-trading/src/trading-table-shared.tsx new file mode 100644 index 0000000000..8071159ce0 --- /dev/null +++ b/examples/react/realtime-trading/src/trading-table-shared.tsx @@ -0,0 +1,317 @@ +import { useMemo } from 'react' +import { useSelector } from '@tanstack/react-store' +import { + DepthCell, + DownMoveCell, + PriceCell, + QuoteAgeCell, + SparklineCell, + SpreadCell, + StableMoveCell, + UpMoveCell, + recordCellRender, +} from './quote-cells' +import { + useTradingShellController, +} from './shell/trading-shell-context' +import type { ReactNode } from 'react' +import type { MarketQuote } from './market-data' + +export type RendererMode = 'stable' | 'swap' +export type TableAdapter = 'local' | 'v8' +export type CoreRowModelMode = 'none' | 'sort' | 'filter' | 'sort-filter' + +export interface TradingTableProps { + quotes: Array + coreRowModelMode: CoreRowModelMode + coreFilterValue: string +} + +export interface CoreTableState { + sorting: Array<{ id: string; desc: boolean }> + columnFilters: Array<{ id: string; value: unknown }> +} + +interface TradingCellContext { + row: { original: MarketQuote } +} + +export interface TradingColumnDefinition { + id: string + header: string + size: number + accessorFn?: (row: MarketQuote) => unknown + filterFn?: 'includesString' + sortFn?: 'basic' + cell: (context: TradingCellContext) => ReactNode +} + +const compactFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) +const currencyFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + style: 'currency', + currency: 'USD', +}) + +export const tradingColumns: Array = [ + { + id: 'symbol', + header: 'Ticker', + size: 90, + accessorFn: (row) => row.symbol, + filterFn: 'includesString', + cell: ({ row }) => recordCellRender('Ticker', row.original.symbol), + }, + { + id: 'venue', + header: 'Venue', + size: 70, + cell: ({ row }) => recordCellRender('Venue', row.original.venue), + }, + { + id: 'bid', + header: 'Bid', + size: 90, + cell: ({ row }) => + recordCellRender('Bid', row.original.bid.toFixed(2)), + }, + { + id: 'ask', + header: 'Ask', + size: 90, + cell: ({ row }) => + recordCellRender('Ask', row.original.ask.toFixed(2)), + }, + { + id: 'spread', + header: 'Spread', + size: 95, + cell: ({ row }) => + recordCellRender( + 'Spread', + , + ), + }, + { + id: 'price', + header: 'Last', + size: 100, + accessorFn: (row) => row.price, + sortFn: 'basic', + cell: ({ row }) => + recordCellRender('Last', ), + }, + { + id: 'lastMove', + header: 'Last Move', + size: 105, + cell: ({ row }) => + recordCellRender('LastMove', ), + }, + { + id: 'lastSize', + header: 'Last Qty', + size: 90, + cell: ({ row }) => + recordCellRender( + 'LastQty', + compactFormatter.format(row.original.lastSize), + ), + }, + { + id: 'depth', + header: 'Bid / Ask Qty', + size: 145, + cell: ({ row }) => + recordCellRender( + 'Depth', + , + ), + }, + { + id: 'age', + header: 'Quote Age', + size: 85, + cell: ({ row }) => + recordCellRender('QuoteAge', ), + }, + { + id: 'change', + header: 'Day %', + size: 90, + cell: ({ row }) => + recordCellRender( + 'DayChange', + formatDayChange(row.original.price, row.original.open), + ), + }, + { + id: 'volume', + header: 'Total Qty', + size: 100, + cell: ({ row }) => + recordCellRender( + 'TotalQty', + compactFormatter.format(row.original.volume), + ), + }, + { + id: 'turnover', + header: 'Traded Value', + size: 115, + cell: ({ row }) => + recordCellRender( + 'TradedValue', + currencyFormatter.format(row.original.turnover), + ), + }, + { + id: 'history', + header: 'Intraday', + size: 150, + cell: ({ row }) => + recordCellRender( + 'Intraday', + , + ), + }, +] + +export const rowModelDiagnostics = { + hasMeasurement: false, + calls: 0, + totalDurationMs: 0, + maxDurationMs: 0, + lastRowCount: 0, +} + +export const TRADING_COLUMN_COUNT = tradingColumns.length + +export function getCoreTableState(props: TradingTableProps) { + const sorts = + props.coreRowModelMode === 'sort' || + props.coreRowModelMode === 'sort-filter' + const filters = + (props.coreRowModelMode === 'filter' || + props.coreRowModelMode === 'sort-filter') && + props.coreFilterValue.trim().length > 0 + + return { + sorting: sorts ? [{ id: 'price', desc: true }] : [], + columnFilters: filters + ? [{ id: 'symbol', value: props.coreFilterValue.trim() }] + : [], + } satisfies CoreTableState +} + +export function useCoreTableState(props: TradingTableProps) { + return useMemo( + () => getCoreTableState(props), + [props.coreFilterValue, props.coreRowModelMode], + ) +} + +export function readMeasuredRows( + adapter: TableAdapter, + readRows: () => Array, +): Array { + const start = performance.now() + const rows = readRows() + const end = performance.now() + const duration = end - start + + rowModelDiagnostics.calls++ + rowModelDiagnostics.hasMeasurement = true + rowModelDiagnostics.totalDurationMs += duration + rowModelDiagnostics.maxDurationMs = Math.max( + rowModelDiagnostics.maxDurationMs, + duration, + ) + rowModelDiagnostics.lastRowCount = rows.length + + try { + performance.measure('tanstack-row-model', { + start, + end, + detail: { adapter, rowCount: rows.length }, + }) + if (rowModelDiagnostics.calls % 1_000 === 0) { + performance.clearMeasures('tanstack-row-model') + } + } catch { + // User Timing Level 3 detail is not implemented in every browser. + } + + return rows +} + +function LastPriceCell(props: { quote: MarketQuote }) { + const { selectSymbol } = useTradingShellController().actions + return ( + selectSymbol(props.quote.symbol)} + /> + ) +} + +function LastMoveCell(props: { quote: MarketQuote }) { + const { rendererMode } = useTradingShellController().renderAtoms + const mode = useSelector(rendererMode) + if (mode === 'stable') { + return + } + return props.quote.lastMove >= 0 ? ( + + ) : ( + + ) +} + +function AgeCell(props: { quote: MarketQuote }) { + const { quoteAge } = useTradingShellController().renderAtoms + const { enabled, clock } = useSelector(quoteAge) + return ( + + ) +} + +export function TradingRow(props: { + quote: MarketQuote + children: ReactNode +}) { + const { selectedSymbol } = useTradingShellController().renderAtoms + const selected = useSelector( + selectedSymbol, + (symbol) => symbol === props.quote.symbol, + ) + return ( + + {props.children} + + ) +} + +function formatDayChange(price: number, open: number): string { + const change = (price / open - 1) * 100 + return `${change >= 0 ? '+' : ''}${change.toFixed(2)}%` +} diff --git a/examples/react/realtime-trading/src/trading-table-v8.tsx b/examples/react/realtime-trading/src/trading-table-v8.tsx new file mode 100644 index 0000000000..f281b7b344 --- /dev/null +++ b/examples/react/realtime-trading/src/trading-table-v8.tsx @@ -0,0 +1,77 @@ +'use no memo' + +import { + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table-v8' +import { memo } from 'react' +import { + TradingRow, + readMeasuredRows, + tradingColumns, + useCoreTableState, +} from './trading-table-shared' +import { useV8TradingTableRuntime } from './core/use-trading-table-runtime' + +export const V8TradingTable = memo(function V8TradingTable() { + const props = useV8TradingTableRuntime() + const coreTableState = useCoreTableState(props) + const table = useReactTable({ + columns: tradingColumns, + data: props.quotes, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getRowId: (row) => row.id, + state: coreTableState, + }) + const rows = readMeasuredRows('v8', () => table.getRowModel().rows) + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {!header.isPlaceholder && + flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+
+ ) +}) diff --git a/examples/react/realtime-trading/src/trading-table.tsx b/examples/react/realtime-trading/src/trading-table.tsx new file mode 100644 index 0000000000..4fb2e19648 --- /dev/null +++ b/examples/react/realtime-trading/src/trading-table.tsx @@ -0,0 +1,15 @@ +export { LocalTradingTable } from './trading-table-local' +export { V8TradingTable } from './trading-table-v8' +export { + TRADING_COLUMN_COUNT, + TradingRow, + getCoreTableState, + rowModelDiagnostics, +} from './trading-table-shared' +export type { + CoreRowModelMode, + CoreTableState, + RendererMode, + TableAdapter, + TradingTableProps, +} from './trading-table-shared' diff --git a/examples/react/realtime-trading/src/vite-env.d.ts b/examples/react/realtime-trading/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/react/realtime-trading/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/react/realtime-trading/tests/e2e/smoke.spec.ts b/examples/react/realtime-trading/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000000..9fd51c0680 --- /dev/null +++ b/examples/react/realtime-trading/tests/e2e/smoke.spec.ts @@ -0,0 +1,170 @@ +import path from 'node:path' +import { expect, test } from '@playwright/test' +import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' + +const exampleDir = path.resolve() + +function collectPageErrors(page: Page) { + const errors: Array = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + return errors +} + +test('runs the same React workload across both table adapters', async ({ + page, +}) => { + const server = await startExampleServer(exampleDir) + const errors = collectPageErrors(page) + + try { + await page.route( + 'https://unpkg.com/react-scan/dist/auto.global.js', + (route) => + route.fulfill({ + contentType: 'application/javascript', + body: '', + }), + ) + + await page.goto(server.url) + + const table = page.getByRole('table') + await expect(table).toBeVisible() + await expect(table.locator('tbody tr')).toHaveCount(250) + await expect(table.locator('thead th')).toHaveCount(14) + await expect(table.locator('thead')).toContainText('Ticker') + await expect(table.locator('thead')).toContainText('Last Qty') + await expect(table.locator('thead')).toContainText('Traded Value') + const adapter = page.getByTestId('adapter-select') + await expect(adapter).toHaveValue('local') + await expect(page.getByTestId('feed-status')).toHaveText('FEED LIVE') + const instrumentCount = page.getByTestId('instrument-count-select') + await expect(instrumentCount.locator('option[value="150"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="350"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="750"]')).toHaveCount(1) + + const loadProfile = page.getByTestId('load-profile-select') + await expect(loadProfile).toHaveValue('high') + await loadProfile.selectOption('very-high') + await expect(page.getByTestId('target-rate-slider')).toHaveValue('25000') + await loadProfile.selectOption('high') + + const rowWorkload = page.getByTestId('row-workload-select') + await rowWorkload.selectOption('rotating-filter') + await expect(table.locator('tbody tr')).toHaveCount(200) + await rowWorkload.selectOption('identity-churn') + await expect(table.locator('tbody tr')).toHaveCount(250) + await expect( + table.locator('tbody tr[data-row-id*="-replacement-"]'), + ).toHaveCount(25) + await rowWorkload.selectOption('price-sort') + await expect(table.locator('tbody tr')).toHaveCount(250) + await rowWorkload.selectOption('stable') + + const coreRowModel = page.getByTestId('core-row-model-select') + await coreRowModel.selectOption('filter') + await expect(page.getByTestId('core-filter-input')).toHaveValue('ALP') + await expect(table.locator('tbody tr')).toHaveCount(21) + await page.getByTestId('core-filter-input').fill('CRN') + await expect(table.locator('tbody tr')).toHaveCount(21) + await coreRowModel.selectOption('none') + await expect(table.locator('tbody tr')).toHaveCount(250) + + await expect + .poll(async () => { + const text = await page.getByTestId('total-events').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => { + const text = await page.getByTestId('worker-messages').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('raf-rate').textContent()), + ) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('table-render-rate').textContent()), + ) + .toBeGreaterThan(0) + + for (const implementation of ['v8', 'local']) { + await adapter.selectOption(implementation) + await expect(adapter).toHaveValue(implementation) + await expect(page.getByRole('table')).toBeVisible() + await expect(page.locator('tbody tr')).toHaveCount(250) + + await coreRowModel.selectOption('filter') + await page.getByTestId('core-filter-input').fill('ALP') + await expect(page.locator('tbody tr')).toHaveCount(21) + await coreRowModel.selectOption('none') + await expect(page.locator('tbody tr')).toHaveCount(250) + + const firstPrice = page.locator('tbody tr').first().getByRole('button') + const priceBeforeUpdate = await firstPrice.textContent() + await expect + .poll(() => firstPrice.textContent()) + .not.toBe(priceBeforeUpdate) + } + + await page.locator('.config-section input[type="checkbox"]').first().check() + await page.getByTestId('feed-toggle').click() + await expect(page.getByTestId('feed-toggle')).toHaveText('START FEED') + await expect(page.getByTestId('feed-status')).toHaveText('FEED PAUSED') + + await coreRowModel.selectOption('sort') + const sortedPrices = await table.locator('tbody tr').evaluateAll((rows) => + rows.map((row) => + Number(row.querySelector('.price-button')?.textContent), + ), + ) + expect(sortedPrices).toEqual([...sortedPrices].sort((a, b) => b - a)) + + await instrumentCount.selectOption('750') + await expect(table.locator('tbody tr')).toHaveCount(750) + await page.getByTestId('scroll-stress-select').selectOption('vertical') + await expect + .poll(() => + page + .locator('.table-scroll') + .evaluate((element) => element.scrollTop), + ) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number( + (await page.getByTestId('scroll-pressure-rate').textContent()) + ?.split('/')[0] + .trim(), + ), + ) + .toBeGreaterThan(0) + await page.getByTestId('scroll-stress-select').selectOption('off') + + await expect + .poll(async () => + Number(await page.getByTestId('profiler-commit-rate').textContent()), + ) + .toBeGreaterThan(0) + expect( + await page.evaluate( + () => + performance.getEntriesByName('react-profiler-commit').length > 0 && + performance.getEntriesByName('tanstack-row-model').length > 0, + ), + ).toBe(true) + + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) diff --git a/examples/react/realtime-trading/tsconfig.json b/examples/react/realtime-trading/tsconfig.json new file mode 100644 index 0000000000..6bb83b8604 --- /dev/null +++ b/examples/react/realtime-trading/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "tests/e2e", "vite.config.ts"] +} diff --git a/examples/react/realtime-trading/vite.config.ts b/examples/react/realtime-trading/vite.config.ts new file mode 100644 index 0000000000..e105049171 --- /dev/null +++ b/examples/react/realtime-trading/vite.config.ts @@ -0,0 +1,42 @@ +import react, { reactCompilerPreset } from '@vitejs/plugin-react' +import babel from '@rolldown/plugin-babel' +import rollupReplace from '@rollup/plugin-replace' +import { defineConfig } from 'vite' + +export default defineConfig(({ mode }) => { + const development = mode === 'development' + const profiling = mode === 'profile' + + return { + server: { + port: 7778, + allowedHosts: true, + }, + plugins: [ + rollupReplace({ + preventAssignment: true, + values: { + __DEV__: JSON.stringify(development), + 'process.env.NODE_ENV': JSON.stringify( + development ? 'development' : 'production', + ), + }, + }), + react(), + babel({ + presets: [reactCompilerPreset()], + include: [/\/src\/.*\.[jt]sx?$/], + }), + ], + resolve: { + alias: profiling + ? [ + { + find: /^react-dom\/client$/, + replacement: 'react-dom/profiling', + }, + ] + : [], + }, + } +}) diff --git a/examples/solid/realtime-trading/README.md b/examples/solid/realtime-trading/README.md new file mode 100644 index 0000000000..fdad66799b --- /dev/null +++ b/examples/solid/realtime-trading/README.md @@ -0,0 +1,208 @@ +# Solid real-time trading FlexRender lab + +This standalone example generates deterministic synthetic quote events in the +browser and stresses two Solid Table render paths. It is not a real +exchange feed and does not display financial advice or real market data. + +The workload is inspired by the public +[AG Grid finance demo](https://www.ag-grid.com/example-finance/) and its +[source](https://github.com/ag-grid/ag-grid-demos/tree/main/finance), but is +focused on renderer lifecycle, immutable high-frequency updates, backpressure, +and browser performance APIs. + +## Run it + +From the repository root: + +```sh +pnpm --filter tanstack-solid-table-example-realtime-trading dev +``` + +Open `http://localhost:7779`. For measurements, build and serve the production +bundle so development checks do not distort the result: + +```sh +pnpm --filter tanstack-solid-table-example-realtime-trading build +pnpm --filter tanstack-solid-table-example-realtime-trading serve +``` + +## What is copied into this example + +This directory is self-contained. It does not import the feed implementation, +styles, components, or benchmark shell from the Angular or React examples: + +- `market-feed-engine.ts` owns the deterministic quote algorithm. +- `market-feed.worker.ts` schedules, batches, coalesces, and applies + backpressure. +- `market-feed-protocol.ts` defines commands and events across the worker + boundary. +- `market-data.ts` creates a new data array and new objects for changed rows. +- `quote-cells.tsx` implements all dynamic Solid cells and lifecycle counters. +- `trading-table.tsx` keeps the local v9 and v8 table construction separate + while sharing one column definition. +- `core/trading-benchmark-controller.ts` owns application state, worker + transport, derived table inputs, and user commands. +- `benchmark/benchmark-monitor.ts` owns browser observers, render + acknowledgements, and published metrics. +- `shell/TradingShell.tsx` composes independent header, toolbar, metrics, + status-bar, diagnostics, and configurator components. +- `shell/trading-shell-context.tsx` lets every shell component consume the + controller without prop drilling. +- `App.tsx` creates the controller and owns only the projected adapter switch. +- `index.css` is a complete local copy of the trading-terminal styles. + +The duplication is intentional: each framework example can be copied, changed, +or profiled on its own. Keep the workload files synchronized manually when +making cross-framework comparisons. + +## Architecture + +`App` deliberately has no knowledge of workers, timers, observers, or adapter +commands. It provides the controller, selects the active adapter, and projects +that table into the shell layout. `createTradingBenchmarkController` is the +single stateful boundary: it subscribes to the feed worker, converts protocol +events into immutable row snapshots, derives the selected workload, and +exposes a small `state` / `actions` contract to the shell context. + +Benchmark instrumentation is a separate collaborator. `BenchmarkMonitor` +contains the mutable sampling runtime and publishes immutable metric snapshots +back through the controller. This keeps measurement policy out of both the +table adapters and the presentational shell. + +All deliberate mutable runtime is grouped behind `const` object identities. +Source files do not use `let`; counters and handles change as properties of +those stable runtime owners instead of being scattered mutable bindings. + +## Adapter matrix + +The configurator mounts exactly one implementation at a time: + +- **Local v9** imports the Solid adapter from this workspace. +- **8.21.3** imports the final published Solid v8 adapter and matching core. + +Changing the select disposes the current table before mounting the next one. +Feed state remains in the controller, so both receive the same immutable +snapshots. +The exact v8 tarball alias prevents this workspace from silently replacing the +published baseline with local packages. + +## Render workload + +The table contains 14 columns and up to 1,000 instruments. Its market-watch +labels are Ticker, Venue, Bid, Ask, Spread, Last, Last Move, Last Qty, +Bid / Ask Qty, Quote Age, Day %, Total Qty, Traded Value, and Intraday. It +combines: + +- primitive bid, ask, daily change, quantity, and traded-value cells; +- a clickable price component; +- a stable Tick component or two alternating Up/Down component types; +- spread, market-depth, quote-age, and sparkline components; +- immutable row replacement with stable instrument IDs; +- optional 100 ms quote-age invalidation for every visible Age cell; and +- optional history-array replacement for sparklines. + +The feed control provides repeatable load profiles: Low 1k/s, Medium 5k/s, +High 10k/s, Very high 25k/s, and Max 100k/s. High is the default; Max is a +deliberate saturation test. Moving the rate slider switches the profile to +Custom. Available universe sizes are 50, 100, 150, 250, 350, 500, 750, and +1,000. + +The row workload selector separates four different behaviors: + +- **Stable universe** preserves source order and IDs. +- **Continuously sort by Last** reorders keyed rows as prices move without + recreating their identity. +- **Rotate 20% filtered rows** changes one excluded index bucket each second, + forcing row removal and reinsertion. +- **Replace 10% of ticker IDs** changes one bucket's IDs and ticker labels each + second. Ten percent are replacements at any instant; transitions dispose the + previous bucket and create the next, crossing lifecycle boundaries for about + twenty percent of rows. + +These transformations run before the selected adapter so both versions +receive the same arrays. They test sorting/filtering consequences and keyed +reconciliation, not the adapters' public sorting/filtering APIs. + +Solid's lifecycle counters use `onMount` and `onCleanup`. Switching from the +stable Tick renderer to the alternating Up/Down renderers intentionally makes +direction changes destroy one component type and mount the other. Stable mode +updates the same component instead. + +## Worker transport + +The Worker acts like an external WebSocket/SSE transport. It permits one batch +in flight. A Solid effect tracking only table-driving signals acknowledges the +batch after the corresponding reactive DOM update. While an update is pending, +the Worker coalesces newer events by instrument in a bounded map instead of +growing an unbounded message queue. + +`batch events` therefore counts source events, while `row updates` counts the +final row snapshots copied into a particular UI batch. A batch can contain +thousands of events but at most one final update per instrument. + +The Worker removes quote generation and batching from the main thread. Solid +rendering, TanStack row-model work, DOM updates, layout, and paint still run on +the main thread. + +## Metrics + +- **Throughput** is the source-event rate represented by acknowledged batches. +- **RAF rate** is actual `requestAnimationFrame` callbacks divided by elapsed + wall time. It is not an FPS estimate derived from table renders. +- **Table renders** is the rate of feed batches that reach the completed Solid + reactive update and are acknowledged. +- **Average / P95 render** spans worker-message receipt through that completed + effect. It excludes worker calculation and browser paint. +- **Long frames** uses the browser Long Animation Frames API when available. +- **Heap** uses Chromium's non-standard `performance.memory` when available. +- **Created / destroyed** distinguishes expected type-swap churn from live + component retention. +- **Cell renderer calls/s** counts executions of column `cell` functions. The + per-column breakdown identifies which callback was invoked. +- **Component renders/s** counts executions of dynamic Solid cell component + functions, with an exact per-type breakdown. Fine-grained reactive DOM + updates normally do not re-execute a whole component, so these calls mostly + indicate component creation rather than React-style rerendering. +- **DOM mutation records/s** comes from a `MutationObserver` attached to the + active `tbody`. It counts observer records, not changed elements or painted + pixels. + +The heap line is diagnostic. Immutable updates continuously allocate short-lived +arrays, row objects, and render objects, so raw heap can rise before garbage +collection. Compare post-GC plateaus in the browser Memory profiler. + +The three counters are deliberately simple but do add instrumentation overhead. +Use them to catch accidental full-table work and compare ratios. For final +timings, corroborate them with a Chrome Performance recording and Solid +DevTools. A realistic healthy run keeps source throughput near target, avoids +reconstructing every cell on every worker batch, and reaches a stable post-GC +heap plateau. + +## Is this a real financial grid? + +The presentation and workload shape are realistic for a market-watch blotter, +but the prices, venues, sizes, volume, and traded value are deterministic +synthetic data. There is no order book, exchange calendar, corporate actions, +network jitter, reconnect logic, entitlement processing, or real WebSocket +decoder. That makes the lab reproducible, not production-representative. + +For higher confidence, replay a timestamped, sanitized capture through the same +worker protocol. Preserve burstiness and symbol skew, then compare adapters +using the same capture, production build, browser, viewport, and fixed +measurement window. + +## Repeatable comparison + +1. Use production builds in the same browser and on the same machine. +2. Start at 250 instruments and 10k events/s. +3. Keep stable Tick cells, quote ages, and sparklines enabled. +4. Reset and warm up for 20โ€“30 seconds. +5. Record throughput, RAF rate, table renders/s, cell/component calls, DOM + mutations, P95, long frames, and post-GC heap over a fixed window. +6. Repeat with the Angular and React standalone examples using identical + controls. +7. Toggle Tick A/B swapping, quote ages, and sparklines separately to isolate + component churn, shared-clock invalidation, and array-input cost. + +The 25k burst is useful for profiling coalescing and a large render, but a +sustained target rate is the better test of steady-state behavior. diff --git a/examples/solid/realtime-trading/index.html b/examples/solid/realtime-trading/index.html new file mode 100644 index 0000000000..37fed0181d --- /dev/null +++ b/examples/solid/realtime-trading/index.html @@ -0,0 +1,16 @@ + + + + + + + Solid Table โ€” Real-time Trading Benchmark + + +
+ + + diff --git a/examples/solid/realtime-trading/package.json b/examples/solid/realtime-trading/package.json new file mode 100644 index 0000000000..4d61e11d84 --- /dev/null +++ b/examples/solid/realtime-trading/package.json @@ -0,0 +1,24 @@ +{ + "name": "tanstack-solid-table-example-realtime-trading", + "private": true, + "type": "module", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "lint": "eslint ./src", + "test:types": "tsc --noEmit", + "test:e2e": "PLAYWRIGHT_TEST_DIR=$PWD/tests/e2e playwright test --config ../../../playwright.config.ts" + }, + "dependencies": { + "@tanstack/solid-table": "^9.0.0", + "@tanstack/solid-table-v8": "https://registry.npmjs.org/@tanstack/solid-table/-/solid-table-8.21.3.tgz", + "solid-js": "^1.9.14" + }, + "devDependencies": { + "typescript": "6.0.3", + "vite": "^8.2.0", + "vite-plugin-solid": "^2.11.14" + } +} diff --git a/examples/solid/realtime-trading/src/App.tsx b/examples/solid/realtime-trading/src/App.tsx new file mode 100644 index 0000000000..3e1f40500e --- /dev/null +++ b/examples/solid/realtime-trading/src/App.tsx @@ -0,0 +1,50 @@ +import { Match, Switch } from 'solid-js' +import { createTradingBenchmarkController } from './core/trading-benchmark-controller' +import { TradingShell } from './shell/TradingShell' +import { + TradingShellProvider, + useTradingShellController, +} from './shell/trading-shell-context' +import { + LocalTradingTable, + V8TradingTable, +} from './trading-table' + +export default function App() { + const controller = createTradingBenchmarkController() + return ( + + + + + + ) +} + +function TradingTableOutlet() { + const { state, actions } = useTradingShellController() + return ( + + + + + + + + + ) +} diff --git a/examples/solid/realtime-trading/src/benchmark-profiles.ts b/examples/solid/realtime-trading/src/benchmark-profiles.ts new file mode 100644 index 0000000000..848fc28880 --- /dev/null +++ b/examples/solid/realtime-trading/src/benchmark-profiles.ts @@ -0,0 +1,65 @@ +import type { MarketQuote } from './market-data' + +export type FeedLoadProfile = + 'low' | 'medium' | 'high' | 'very-high' | 'max' | 'custom' + +export type RowWorkloadMode = + 'stable' | 'price-sort' | 'rotating-filter' | 'identity-churn' + +export const feedLoadRates: Record< + Exclude, + number +> = { + low: 1_000, + medium: 5_000, + high: 10_000, + 'very-high': 25_000, + max: 100_000, +} + +export function deriveBenchmarkQuotes( + quotes: Array, + mode: RowWorkloadMode, + epoch: number, +): Array { + if (mode === 'stable') { + return quotes + } + + if (mode === 'price-sort') { + return [...quotes].sort( + (left, right) => + right.price - left.price || left.symbol.localeCompare(right.symbol), + ) + } + + if (mode === 'rotating-filter') { + const excludedBucket = epoch % 5 + return quotes.filter((_, index) => index % 5 !== excludedBucket) + } + + const replacementBucket = epoch % 10 + return quotes.map((quote, index) => + index % 10 === replacementBucket + ? { + ...quote, + id: `${quote.id}-replacement-${epoch}`, + symbol: `${quote.symbol}R${epoch % 100}`, + company: `${quote.company} replacement`, + } + : quote, + ) +} + +export function rowWorkloadLabel(mode: RowWorkloadMode): string { + switch (mode) { + case 'price-sort': + return 'PRICE REORDER' + case 'rotating-filter': + return 'FILTER ROTATION' + case 'identity-churn': + return 'TICKER REPLACEMENT' + default: + return 'STABLE UNIVERSE' + } +} diff --git a/examples/solid/realtime-trading/src/benchmark/benchmark-monitor.ts b/examples/solid/realtime-trading/src/benchmark/benchmark-monitor.ts new file mode 100644 index 0000000000..c0031a812f --- /dev/null +++ b/examples/solid/realtime-trading/src/benchmark/benchmark-monitor.ts @@ -0,0 +1,286 @@ +import { quoteCellLifecycle, quoteRenderDiagnostics } from '../quote-cells' +import type { MarketFeedCommand } from '../market-feed-protocol' + +export interface NamedInvocationRate { + name: string + callsPerSecond: number +} + +export interface FeedMetrics { + actualEventsPerSecond: number + totalEvents: number + rafCallbacksPerSecond: number + tableRendersPerSecond: number + lastBatchSize: number + averageRenderMs: number + p95RenderMs: number + maxRenderMs: number + slowRenders: number + longAnimationFrames: number + worstLongAnimationFrameMs: number + heapMb: number | null + componentsCreated: number + componentsDestroyed: number + workerMessages: number + lastUpdateCount: number + cellRendererCallsPerSecond: number + componentRenderCallsPerSecond: number + cellRendererRates: ReadonlyArray + componentRenderRates: ReadonlyArray + domMutationsPerSecond: number +} + +export const initialMetrics: FeedMetrics = { + actualEventsPerSecond: 0, + totalEvents: 0, + rafCallbacksPerSecond: 0, + tableRendersPerSecond: 0, + lastBatchSize: 0, + averageRenderMs: 0, + p95RenderMs: 0, + maxRenderMs: 0, + slowRenders: 0, + longAnimationFrames: 0, + worstLongAnimationFrameMs: 0, + heapMb: null, + componentsCreated: 0, + componentsDestroyed: 0, + workerMessages: 0, + lastUpdateCount: 0, + cellRendererCallsPerSecond: 0, + componentRenderCallsPerSecond: 0, + cellRendererRates: [], + componentRenderRates: [], + domMutationsPerSecond: 0, +} + +interface PendingAck { + generation: number + sequence: number +} + +export class BenchmarkMonitor { + readonly #runtime = { + sampleStartedAt: performance.now(), + pendingRenderStartedAt: null as number | null, + pendingAck: null as PendingAck | null, + renderSamples: [] as Array, + totalEvents: 0, + eventsInSample: 0, + lastBatchSize: 0, + lastUpdateCount: 0, + workerMessages: 0, + rafCallbacksInSample: 0, + tableRendersInSample: 0, + longAnimationFrameCount: 0, + worstLongAnimationFrameMs: 0, + previousCellRendererCalls: 0, + previousComponentRenderCalls: 0, + previousCellRendererCallsByName: { + ...quoteRenderDiagnostics.cellRendererCallsByName, + }, + previousComponentRenderCallsByName: { + ...quoteRenderDiagnostics.componentRenderCallsByName, + }, + domMutationsInSample: 0, + } + + markRenderPending(): void { + this.#runtime.pendingRenderStartedAt ??= performance.now() + } + + setPendingAck(ack: PendingAck | null): void { + this.#runtime.pendingAck = ack + } + + recordCompletedRender(postCommand: (command: MarketFeedCommand) => void) { + const runtime = this.#runtime + if (runtime.pendingRenderStartedAt !== null) { + runtime.renderSamples.push( + performance.now() - runtime.pendingRenderStartedAt, + ) + runtime.pendingRenderStartedAt = null + } + + if (runtime.pendingAck) { + runtime.tableRendersInSample++ + postCommand({ type: 'ack', ...runtime.pendingAck }) + runtime.pendingAck = null + } + } + + recordBatch(eventCount: number, updateCount: number): void { + const runtime = this.#runtime + runtime.lastBatchSize = eventCount + runtime.lastUpdateCount = updateCount + runtime.eventsInSample += eventCount + runtime.totalEvents += eventCount + runtime.workerMessages++ + } + + recordAnimationFrame(): void { + this.#runtime.rafCallbacksInSample++ + } + + recordLongAnimationFrame(duration: number): void { + const runtime = this.#runtime + runtime.longAnimationFrameCount++ + runtime.worstLongAnimationFrameMs = Math.max( + runtime.worstLongAnimationFrameMs, + duration, + ) + } + + recordDomMutations(count: number): void { + this.#runtime.domMutationsInSample += count + } + + resetDomMutations(): void { + this.#runtime.domMutationsInSample = 0 + } + + shouldPublish(now: number): boolean { + return now - this.#runtime.sampleStartedAt >= 500 + } + + publish(now: number): FeedMetrics { + const runtime = this.#runtime + const sampleDuration = now - runtime.sampleStartedAt + const cellRendererCalls = + quoteRenderDiagnostics.cellRendererCalls - + runtime.previousCellRendererCalls + const componentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls - + runtime.previousComponentRenderCalls + const cellRendererRates = calculateInvocationRates( + quoteRenderDiagnostics.cellRendererCallsByName, + runtime.previousCellRendererCallsByName, + sampleDuration, + ) + const componentRenderRates = calculateInvocationRates( + quoteRenderDiagnostics.componentRenderCallsByName, + runtime.previousComponentRenderCallsByName, + sampleDuration, + ) + const sortedRenderSamples = [...runtime.renderSamples].sort( + (left, right) => left - right, + ) + const averageRenderMs = + runtime.renderSamples.length === 0 + ? 0 + : runtime.renderSamples.reduce((sum, value) => sum + value, 0) / + runtime.renderSamples.length + const p95Index = Math.max( + 0, + Math.ceil(sortedRenderSamples.length * 0.95) - 1, + ) + const metrics: FeedMetrics = { + actualEventsPerSecond: + sampleDuration === 0 + ? 0 + : (runtime.eventsInSample / sampleDuration) * 1_000, + totalEvents: runtime.totalEvents, + rafCallbacksPerSecond: + sampleDuration === 0 + ? 0 + : (runtime.rafCallbacksInSample / sampleDuration) * 1_000, + tableRendersPerSecond: + sampleDuration === 0 + ? 0 + : (runtime.tableRendersInSample / sampleDuration) * 1_000, + lastBatchSize: runtime.lastBatchSize, + averageRenderMs, + p95RenderMs: sortedRenderSamples[p95Index] ?? 0, + maxRenderMs: sortedRenderSamples.at(-1) ?? 0, + slowRenders: runtime.renderSamples.filter((value) => value > 16.7) + .length, + longAnimationFrames: runtime.longAnimationFrameCount, + worstLongAnimationFrameMs: runtime.worstLongAnimationFrameMs, + heapMb: readHeapSizeMb(), + componentsCreated: quoteCellLifecycle.created, + componentsDestroyed: quoteCellLifecycle.destroyed, + workerMessages: runtime.workerMessages, + lastUpdateCount: runtime.lastUpdateCount, + cellRendererCallsPerSecond: + (cellRendererCalls / sampleDuration) * 1_000, + componentRenderCallsPerSecond: + (componentRenderCalls / sampleDuration) * 1_000, + cellRendererRates, + componentRenderRates, + domMutationsPerSecond: + (runtime.domMutationsInSample / sampleDuration) * 1_000, + } + + runtime.sampleStartedAt = now + runtime.previousCellRendererCalls = + quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.eventsInSample = 0 + runtime.renderSamples = [] + runtime.rafCallbacksInSample = 0 + runtime.tableRendersInSample = 0 + return metrics + } + + reset(): void { + const runtime = this.#runtime + runtime.sampleStartedAt = performance.now() + runtime.pendingRenderStartedAt = null + runtime.pendingAck = null + runtime.renderSamples = [] + runtime.totalEvents = 0 + runtime.eventsInSample = 0 + runtime.lastBatchSize = 0 + runtime.lastUpdateCount = 0 + runtime.workerMessages = 0 + runtime.rafCallbacksInSample = 0 + runtime.tableRendersInSample = 0 + runtime.longAnimationFrameCount = 0 + runtime.worstLongAnimationFrameMs = 0 + runtime.previousCellRendererCalls = + quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + } +} + +export const longAnimationFramesSupported = + PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') + +function readHeapSizeMb(): number | null { + const memory = ( + performance as Performance & { + memory?: { usedJSHeapSize: number } + } + ).memory + return memory ? memory.usedJSHeapSize / 1_048_576 : null +} + +function calculateInvocationRates( + current: Record, + previous: Record, + sampleDuration: number, +): ReadonlyArray { + return Object.entries(current).map(([name, calls]) => ({ + name, + callsPerSecond: + sampleDuration === 0 + ? 0 + : ((calls - (previous[name] ?? 0)) / sampleDuration) * 1_000, + })) +} diff --git a/examples/solid/realtime-trading/src/core/trading-benchmark-controller.ts b/examples/solid/realtime-trading/src/core/trading-benchmark-controller.ts new file mode 100644 index 0000000000..674ae71497 --- /dev/null +++ b/examples/solid/realtime-trading/src/core/trading-benchmark-controller.ts @@ -0,0 +1,300 @@ +import { + createEffect, + createMemo, + createSignal, + onCleanup, + onMount, +} from 'solid-js' +import { + BenchmarkMonitor, + initialMetrics, + longAnimationFramesSupported, +} from '../benchmark/benchmark-monitor' +import { + deriveBenchmarkQuotes, + feedLoadRates, + rowWorkloadLabel, +} from '../benchmark-profiles' +import { applyMarketUpdates, hydrateMarketQuotes } from '../market-data' +import { TRADING_COLUMN_COUNT } from '../trading-table' +import type { FeedLoadProfile, RowWorkloadMode } from '../benchmark-profiles' +import type { MarketFeedCommand, MarketFeedEvent } from '../market-feed-protocol' +import type { MarketQuote } from '../market-data' +import type { RendererMode, TableAdapter } from '../trading-table' + +export function createTradingBenchmarkController() { + const [workerReady, setWorkerReady] = createSignal(false) + const [running, setRunning] = createSignal(true) + const [tableAdapter, setTableAdapter] = createSignal('local') + const [instrumentCount, setInstrumentCount] = createSignal(250) + const [feedLoadProfile, setFeedLoadProfile] = + createSignal('high') + const [targetEventsPerSecond, setTargetEventsPerSecond] = createSignal(10_000) + const [rowWorkloadMode, setRowWorkloadMode] = + createSignal('stable') + const [rowWorkloadEpoch, setRowWorkloadEpoch] = createSignal(0) + const [rendererMode, setRendererMode] = createSignal('stable') + const [updateSparklines, setUpdateSparklines] = createSignal(true) + const [updateQuoteAges, setUpdateQuoteAges] = createSignal(true) + const [quoteClock, setQuoteClock] = createSignal(Date.now()) + const [quotes, setQuotes] = createSignal>([]) + const [selectedSymbol, setSelectedSymbol] = createSignal(null) + const [metrics, setMetrics] = createSignal(initialMetrics) + const monitor = new BenchmarkMonitor() + const runtime = { + worker: null as Worker | null, + animationFrameId: 0, + longAnimationFrameObserver: null as PerformanceObserver | null, + feedGeneration: 0, + lastAgeClockAt: performance.now(), + lastRowWorkloadAt: performance.now(), + } + + const postToWorker = (command: MarketFeedCommand) => + runtime.worker?.postMessage(command) + + createEffect(() => { + quotes() + quoteClock() + monitor.recordCompletedRender(postToWorker) + }) + + createEffect(() => { + tableAdapter() + const tableBody = document.querySelector( + '.market-panel [data-table-adapter] tbody', + ) + if (!tableBody) { + return + } + + monitor.resetDomMutations() + const observer = new MutationObserver((records) => { + monitor.recordDomMutations(records.length) + }) + observer.observe(tableBody, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }) + onCleanup(() => observer.disconnect()) + }) + + const handleWorkerMessage = ({ + data, + }: MessageEvent): void => { + if (data.type === 'ready') { + runtime.feedGeneration = data.generation + monitor.setPendingAck(null) + monitor.markRenderPending() + setQuotes(hydrateMarketQuotes(data.quotes)) + setWorkerReady(true) + return + } + + if (data.generation !== runtime.feedGeneration) { + postToWorker({ + type: 'ack', + generation: data.generation, + sequence: data.sequence, + }) + return + } + + monitor.markRenderPending() + setQuotes((currentQuotes) => + applyMarketUpdates(currentQuotes, data.updates), + ) + monitor.recordBatch(data.eventCount, data.updates.length) + monitor.setPendingAck({ + generation: data.generation, + sequence: data.sequence, + }) + } + + const handleWorkerError = (error: ErrorEvent): void => { + setWorkerReady(false) + setRunning(false) + console.error('Market feed worker failed', error) + } + + const feedFrame = (now: number): void => { + monitor.recordAnimationFrame() + + if (updateQuoteAges() && now - runtime.lastAgeClockAt >= 100) { + monitor.markRenderPending() + runtime.lastAgeClockAt = now + setQuoteClock(Date.now()) + } + + if ( + running() && + (rowWorkloadMode() === 'rotating-filter' || + rowWorkloadMode() === 'identity-churn') && + now - runtime.lastRowWorkloadAt >= 1_000 + ) { + monitor.markRenderPending() + runtime.lastRowWorkloadAt = now + setRowWorkloadEpoch((epoch) => epoch + 1) + } + + if (monitor.shouldPublish(now)) { + setMetrics(monitor.publish(now)) + } + + runtime.animationFrameId = requestAnimationFrame(feedFrame) + } + + onMount(() => { + runtime.worker = new Worker( + new URL('../market-feed.worker.ts', import.meta.url), + { type: 'module' }, + ) + runtime.worker.addEventListener('message', handleWorkerMessage) + runtime.worker.addEventListener('error', handleWorkerError) + + if (longAnimationFramesSupported) { + runtime.longAnimationFrameObserver = new PerformanceObserver((entries) => { + for (const entry of entries.getEntries()) { + monitor.recordLongAnimationFrame(entry.duration) + } + }) + runtime.longAnimationFrameObserver.observe({ + type: 'long-animation-frame', + buffered: true, + }) + } + + postToWorker({ + type: 'initialize', + rowCount: instrumentCount(), + seed: 42 + instrumentCount(), + running: running(), + targetEventsPerSecond: targetEventsPerSecond(), + updateSparklines: updateSparklines(), + }) + runtime.animationFrameId = requestAnimationFrame(feedFrame) + }) + + onCleanup(() => { + cancelAnimationFrame(runtime.animationFrameId) + runtime.longAnimationFrameObserver?.disconnect() + runtime.worker?.terminate() + runtime.worker = null + }) + + const resetWorkerMarket = (rowCount: number): void => { + setWorkerReady(false) + monitor.setPendingAck(null) + postToWorker({ + type: 'reset', + rowCount, + seed: 42 + rowCount, + }) + } + + const actions = { + toggleFeed(): void { + const nextRunning = !running() + setRunning(nextRunning) + postToWorker({ type: 'configure', running: nextRunning }) + }, + setInstrumentCount(count: number): void { + setInstrumentCount(count) + setRowWorkloadEpoch(0) + resetWorkerMarket(count) + }, + setFeedLoadProfile(profile: FeedLoadProfile): void { + setFeedLoadProfile(profile) + if (profile === 'custom') { + return + } + const rate = feedLoadRates[profile] + setTargetEventsPerSecond(rate) + postToWorker({ type: 'configure', targetEventsPerSecond: rate }) + }, + setTargetEventsPerSecond(rate: number): void { + setFeedLoadProfile('custom') + setTargetEventsPerSecond(rate) + postToWorker({ type: 'configure', targetEventsPerSecond: rate }) + }, + setRowWorkloadMode(mode: RowWorkloadMode): void { + setRowWorkloadMode(mode) + setRowWorkloadEpoch(0) + runtime.lastRowWorkloadAt = performance.now() + setSelectedSymbol(null) + }, + setTableAdapter, + setRendererMode, + setUpdateQuoteAges, + setUpdateSparklines(enabled: boolean): void { + setUpdateSparklines(enabled) + postToWorker({ type: 'configure', updateSparklines: enabled }) + }, + selectSymbol: setSelectedSymbol, + runBurst(): void { + postToWorker({ type: 'burst', eventCount: 25_000 }) + }, + resetMarket(): void { + setSelectedSymbol(null) + monitor.reset() + runtime.lastRowWorkloadAt = performance.now() + setRowWorkloadEpoch(0) + setQuoteClock(Date.now()) + setMetrics({ ...initialMetrics }) + resetWorkerMarket(instrumentCount()) + }, + } + + const displayQuotes = createMemo(() => + deriveBenchmarkQuotes(quotes(), rowWorkloadMode(), rowWorkloadEpoch()), + ) + const selectedQuote = createMemo( + () => + displayQuotes().find((quote) => quote.symbol === selectedSymbol()) ?? + null, + ) + const mountedCells = createMemo( + () => displayQuotes().length * TRADING_COLUMN_COUNT, + ) + const liveComponents = createMemo( + () => metrics().componentsCreated - metrics().componentsDestroyed, + ) + const adapterLabel = createMemo(() => + tableAdapter() === 'local' ? 'LOCAL V9' : 'V8.21.3', + ) + const workloadLabel = createMemo(() => + rowWorkloadLabel(rowWorkloadMode()), + ) + + return { + state: { + workerReady, + running, + tableAdapter, + instrumentCount, + feedLoadProfile, + targetEventsPerSecond, + rowWorkloadMode, + rendererMode, + updateSparklines, + updateQuoteAges, + quoteClock, + quotes, + selectedSymbol, + metrics, + displayQuotes, + selectedQuote, + mountedCells, + liveComponents, + adapterLabel, + workloadLabel, + }, + actions, + } +} + +export type TradingBenchmarkController = ReturnType< + typeof createTradingBenchmarkController +> diff --git a/examples/solid/realtime-trading/src/index.css b/examples/solid/realtime-trading/src/index.css new file mode 100644 index 0000000000..31c314c951 --- /dev/null +++ b/examples/solid/realtime-trading/src/index.css @@ -0,0 +1,722 @@ +:root { + color-scheme: dark; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-synthesis: none; + --background: #090c11; + --panel: #10151c; + --panel-raised: #151b24; + --panel-hover: #19212b; + --border: #28313d; + --border-soft: #1d2530; + --text: #d7dde5; + --text-strong: #f1f4f8; + --muted: #7f8a98; + --blue: #4f8cff; + --blue-soft: #8cb5ff; + --green: #42c98a; + --red: #ef6a78; + --amber: #e8b95f; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-width: 320px; + min-height: 100%; + margin: 0; + background: var(--background); +} + +body { + color: var(--text); +} + +button, +select, +input { + font: inherit; +} + +button, +select { + color: var(--text); + background: var(--panel-raised); + border: 1px solid #394452; + border-radius: 2px; +} + +button { + padding: 0.5rem 0.65rem; + cursor: pointer; + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.025em; +} + +button:hover { + background: #1c2530; + border-color: #536171; +} + +button:focus-visible, +select:focus-visible, +input:focus-visible { + outline: 1px solid var(--blue); + outline-offset: 1px; +} + +select { + width: 100%; + padding: 0.45rem 0.55rem; + font-size: 0.75rem; +} + +.trading-terminal { + display: flex; + flex-direction: column; + width: 100%; + height: 100vh; + min-height: 640px; + overflow: hidden; + background: var(--background); +} + +.app-bar { + display: flex; + flex: 0 0 42px; + align-items: center; + justify-content: space-between; + padding: 0 0.75rem; + background: #0d1117; + border-bottom: 1px solid var(--border); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.68rem; + letter-spacing: 0.045em; +} + +.brand, +.session-info, +.feed-status { + display: flex; + align-items: center; +} + +.brand { + gap: 0.65rem; + color: var(--text-strong); +} + +.brand-mark { + display: grid; + width: 24px; + height: 24px; + place-items: center; + color: #fff; + background: var(--blue); + font-size: 0.62rem; + font-weight: 800; +} + +.environment { + padding: 0.16rem 0.3rem; + color: var(--amber); + background: rgb(232 185 95 / 8%); + border: 1px solid rgb(232 185 95 / 40%); + font-size: 0.58rem; +} + +.session-info { + gap: 1rem; + color: var(--muted); +} + +.feed-status { + gap: 0.4rem; + color: #9aa4b1; +} + +.feed-status.is-running { + color: var(--green); +} + +.status-dot { + width: 6px; + height: 6px; + background: #66717d; + border-radius: 50%; +} + +.feed-status.is-running .status-dot { + background: var(--green); +} + +.development-warning { + flex: 0 0 auto; + padding: 0.3rem 0.75rem; + color: #e9c67e; + background: #211b11; + border-bottom: 1px solid #4a3d25; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.62rem; + letter-spacing: 0.04em; +} + +.workspace { + display: grid; + flex: 1; + grid-template-columns: minmax(0, 1fr) 288px; + min-height: 0; +} + +.market-panel { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + background: var(--panel); + border-right: 1px solid var(--border); +} + +.market-toolbar { + display: flex; + flex: 0 0 42px; + align-items: center; + justify-content: space-between; + padding: 0 0.75rem; + background: #11171f; + border-bottom: 1px solid var(--border); +} + +.watchlist-name { + display: flex; + gap: 0.6rem; + align-items: baseline; + font-size: 0.67rem; +} + +.watchlist-name span { + color: var(--muted); +} + +.watchlist-name strong { + color: var(--text-strong); + font-size: 0.72rem; + letter-spacing: 0.035em; +} + +.market-context { + display: flex; + gap: 1rem; + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.6rem; +} + +.metrics-strip { + display: grid; + flex: 0 0 64px; + grid-template-columns: repeat(7, minmax(0, 1fr)); + background: var(--border); + border-bottom: 1px solid var(--border); + gap: 1px; +} + +.metrics-strip article { + min-width: 0; + padding: 0.55rem 0.65rem; + background: #0e131a; +} + +.metrics-strip span, +.metrics-strip small { + display: block; + overflow: hidden; + color: var(--muted); + font-size: 0.56rem; + letter-spacing: 0.045em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.metrics-strip strong { + display: block; + margin: 0.22rem 0 0.12rem; + overflow: hidden; + color: var(--text-strong); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.88rem; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; +} + +.metrics-strip strong.metric-alert { + color: var(--amber); +} + +.table-scroll { + flex: 1; + min-height: 0; + overflow: auto; + background: #0d1218; +} + +app-current-trading-table, +app-v8-trading-table { + display: flex; + flex: 1; + min-width: 0; + min-height: 0; +} + +table { + min-width: 100%; + border-spacing: 0; + table-layout: fixed; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; + font-variant-numeric: tabular-nums; +} + +thead { + position: sticky; + z-index: 2; + top: 0; +} + +th { + height: 28px; + padding: 0 0.6rem; + color: #8e99a6; + background: #171e27; + border-right: 1px solid #222b36; + border-bottom: 1px solid #394452; + font-size: 0.58rem; + font-weight: 700; + letter-spacing: 0.055em; + text-align: left; + text-transform: uppercase; +} + +td { + height: 27px; + padding: 0 0.6rem; + overflow: hidden; + color: #cbd2db; + border-right: 1px solid #1a222c; + border-bottom: 1px solid #1b232d; + text-overflow: ellipsis; + white-space: nowrap; +} + +th:last-child, +td:last-child { + border-right: 0; +} + +.numeric-cell { + text-align: right; +} + +tbody tr:nth-child(even) { + background: rgb(255 255 255 / 1.3%); +} + +tbody tr:hover { + background: #17202a; +} + +tbody tr.is-selected { + background: rgb(79 140 255 / 13%); + box-shadow: inset 2px 0 0 var(--blue); +} + +.price-button { + min-width: 4.5rem; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-variant-numeric: tabular-nums; + font-weight: 600; + text-align: right; +} + +.price-button:hover { + background: transparent; + text-decoration: underline; + text-underline-offset: 2px; +} + +.move-cell { + display: inline-block; + min-width: 4.8rem; +} + +.spread-cell { + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: 0.35rem; + color: #cbd2db; +} + +.spread-cell small { + min-width: 2.8rem; + color: var(--muted); + font-size: 0.55rem; +} + +.spread-cell.spread-wide, +.spread-cell.spread-wide small { + color: var(--amber); +} + +app-depth-cell { + display: block; + width: 100%; +} + +.depth-cell { + position: relative; + display: flex; + height: 17px; + overflow: hidden; + background: #151b23; +} + +.depth-bid, +.depth-ask { + height: 100%; + opacity: 0.28; +} + +.depth-bid { + background: var(--blue); + border-right: 1px solid #10151c; +} + +.depth-ask { + background: var(--red); +} + +.depth-values { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.28rem; + color: #dce2e9; + font-size: 0.57rem; + text-shadow: 0 1px #080b0f; +} + +.quote-age { + color: #aab3bf; +} + +.quote-age-warm { + color: var(--amber); +} + +.quote-age-stale { + color: var(--red); +} + +.quote-up { + color: var(--green); +} + +.quote-down { + color: var(--red); +} + +.sparkline { + display: block; + width: 8rem; + height: 1.2rem; + margin-left: auto; + overflow: visible; +} + +.sparkline polyline { + fill: none; + stroke: var(--blue-soft); + stroke-linecap: square; + stroke-linejoin: miter; + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} + +.market-statusbar { + display: flex; + flex: 0 0 25px; + gap: 1.1rem; + align-items: center; + padding: 0 0.65rem; + color: var(--muted); + background: #0d1117; + border-top: 1px solid var(--border); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.56rem; + letter-spacing: 0.035em; +} + +.market-statusbar strong { + margin-left: 0.25rem; + color: #c4ccd6; + font-weight: 500; +} + +.statusbar-spacer { + flex: 1; +} + +.configurator { + min-height: 0; + overflow: auto; + background: #0d1218; +} + +.configurator > header { + display: flex; + height: 42px; + align-items: center; + justify-content: space-between; + padding: 0 0.75rem; + color: var(--text-strong); + background: #11171f; + border-bottom: 1px solid var(--border); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.055em; +} + +.configurator > header small { + color: var(--muted); + font-size: 0.54rem; + font-weight: 500; +} + +.config-section { + display: grid; + gap: 0.7rem; + padding: 0.8rem 0.75rem; + border-bottom: 1px solid var(--border); +} + +.config-section h2 { + margin: 0; + color: #8e99a6; + font-size: 0.58rem; + letter-spacing: 0.075em; +} + +.primary-action { + width: 100%; + color: #fff; + background: #316fdd; + border-color: #4f8cff; +} + +.primary-action:hover { + background: #397bed; + border-color: #73a2ff; +} + +.field { + display: grid; + gap: 0.35rem; + color: #9ba5b1; + font-size: 0.66rem; +} + +.rate-field > span { + display: flex; + justify-content: space-between; +} + +.rate-field strong { + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-weight: 500; +} + +.field small { + color: #66717e; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.55rem; +} + +input[type='range'] { + width: 100%; + margin: 0; + accent-color: var(--blue); +} + +.toggle-field { + display: flex; + gap: 0.5rem; + align-items: flex-start; + color: #b8c0ca; + font-size: 0.66rem; + line-height: 1.3; +} + +.toggle-field input { + margin: 0.12rem 0 0; + accent-color: var(--blue); +} + +.toggle-field small { + display: block; + margin-top: 0.14rem; + color: #687482; + font-size: 0.57rem; +} + +.action-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.4rem; +} + +.diagnostics dl, +.selected-instrument dl { + display: grid; + gap: 0; + margin: 0; +} + +.diagnostics dl > div, +.selected-instrument dl > div { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 24px; + border-bottom: 1px solid var(--border-soft); +} + +.diagnostics dl > div:last-child, +.selected-instrument dl > div:last-child { + border-bottom: 0; +} + +dt { + color: var(--muted); + font-size: 0.61rem; +} + +dd { + margin: 0; + color: #d4dae2; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.62rem; +} + +.selection { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.selection div { + display: grid; + gap: 0.18rem; +} + +.selection strong { + color: var(--text-strong); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85rem; +} + +.selection span, +.selection small, +.selected-instrument p { + color: var(--muted); + font-size: 0.61rem; +} + +.selected-instrument p { + margin: 0; + line-height: 1.45; +} + +@media (max-width: 980px) { + .workspace { + grid-template-columns: minmax(0, 1fr) 250px; + } + + .market-context span:not(:first-child) { + display: none; + } + + .metrics-strip { + grid-template-columns: repeat(3, 1fr); + flex-basis: 166px; + } +} + +@media (max-width: 720px) { + html, + body { + min-height: 100%; + } + + body { + overflow: auto; + } + + .trading-terminal { + height: auto; + min-height: 100vh; + overflow: visible; + } + + .session-info > span:first-child { + display: none; + } + + .workspace { + display: flex; + flex-direction: column; + } + + .market-panel { + min-height: 68vh; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .metrics-strip { + grid-template-columns: repeat(2, 1fr); + flex-basis: 220px; + } + + .table-scroll { + height: 58vh; + flex: none; + } + + .market-context { + display: none; + } + + .configurator { + overflow: visible; + } +} diff --git a/examples/solid/realtime-trading/src/index.tsx b/examples/solid/realtime-trading/src/index.tsx new file mode 100644 index 0000000000..3b0ef7f19f --- /dev/null +++ b/examples/solid/realtime-trading/src/index.tsx @@ -0,0 +1,8 @@ +import { render } from 'solid-js/web' +import App from './App' +import './index.css' + +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Failed to find the root element') + +render(() => , rootElement) diff --git a/examples/solid/realtime-trading/src/market-data.ts b/examples/solid/realtime-trading/src/market-data.ts new file mode 100644 index 0000000000..02eefa28c3 --- /dev/null +++ b/examples/solid/realtime-trading/src/market-data.ts @@ -0,0 +1,38 @@ +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './market-feed-protocol' + +export interface MarketQuote extends Omit { + history: ReadonlyArray +} + +export function hydrateMarketQuotes( + snapshots: Array, +): Array { + return snapshots.map((quote) => ({ + ...quote, + history: [...quote.history], + })) +} + +export function applyMarketUpdates( + quotes: Array, + updates: Array, +): Array { + const nextQuotes = [...quotes] + + for (const update of updates) { + const { index, history, ...values } = update + const previousQuote = quotes.at(index) + if (!previousQuote) continue + + nextQuotes[index] = { + ...previousQuote, + ...values, + history: history ?? previousQuote.history, + } + } + + return nextQuotes +} diff --git a/examples/solid/realtime-trading/src/market-feed-engine.ts b/examples/solid/realtime-trading/src/market-feed-engine.ts new file mode 100644 index 0000000000..da91131351 --- /dev/null +++ b/examples/solid/realtime-trading/src/market-feed-engine.ts @@ -0,0 +1,169 @@ +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const baseInstruments = [ + ['ALP', 'Alpine Systems', 'XNAS'], + ['ARC', 'Arcadia Cloud', 'XNYS'], + ['BLU', 'Blue River Energy', 'BATS'], + ['CRN', 'Crown Robotics', 'XNAS'], + ['DYN', 'Dynasty Networks', 'XNYS'], + ['ECO', 'Ecoframe Materials', 'IEX'], + ['FLX', 'Flux Semiconductors', 'XNAS'], + ['GEO', 'Geode Analytics', 'BATS'], + ['HLX', 'Helix Biotech', 'XNYS'], + ['ION', 'Ion Mobility', 'IEX'], + ['JDE', 'Jade Financial', 'XNYS'], + ['KNT', 'Kinetic Aerospace', 'XNAS'], +] as const + +export class MarketFeedEngine { + #quotes: Array = [] + #random = createRandom(2_026) + #rowCursor = 0 + #historyTick = 0 + #eventIndex = 0 + + reset(count: number, seed: number): Array { + const random = createRandom(seed) + + this.#quotes = Array.from({ length: count }, (_, index) => { + const [baseSymbol, company, venue] = + baseInstruments[index % baseInstruments.length] + const series = Math.floor(index / baseInstruments.length) + const symbol = series === 0 ? baseSymbol : `${baseSymbol}${series}` + const open = roundPrice(20 + random() * 480) + const spread = Math.max(0.01, open * (0.0002 + random() * 0.0004)) + const history = Array.from({ length: 24 }, (_, historyIndex) => + roundPrice( + open * + (1 + Math.sin(historyIndex / 4) * 0.002 + (random() - 0.5) * 0.001), + ), + ) + const volume = Math.floor(50_000 + random() * 2_000_000) + const lastSize = Math.floor(10 + random() * 5_000) + + return { + id: `instrument-${index}`, + symbol, + company, + venue, + open, + price: open, + bid: roundPrice(open - spread / 2), + ask: roundPrice(open + spread / 2), + bidSize: Math.floor(100 + random() * 25_000), + askSize: Math.floor(100 + random() * 25_000), + lastSize, + lastMove: 0, + lastUpdatedAt: Date.now(), + volume, + turnover: roundMoney(open * volume), + history, + } + }) + + this.#random = createRandom(2_026 + seed) + this.#rowCursor = 0 + this.#historyTick = 0 + + return this.#quotes.map((quote) => ({ + ...quote, + history: [...quote.history], + })) + } + + applyEvents( + eventCount: number, + updateSparklines: boolean, + ): Array { + if (this.#quotes.length === 0 || eventCount <= 0) return [] + + const updatedAt = Date.now() + const updatedQuotes = new Map() + const stride = 97 + + this.#eventIndex = 0 + while (this.#eventIndex < eventCount) { + this.#rowCursor = (this.#rowCursor + stride) % this.#quotes.length + const quote = this.#quotes[this.#rowCursor] + const shouldUpdateHistory = + updateSparklines && this.#historyTick++ % 4 === 0 + + this.#applyTick(quote, shouldUpdateHistory, updatedAt) + + const previousUpdate = updatedQuotes.get(this.#rowCursor) + updatedQuotes.set(this.#rowCursor, { + index: this.#rowCursor, + price: quote.price, + bid: quote.bid, + ask: quote.ask, + bidSize: quote.bidSize, + askSize: quote.askSize, + lastSize: quote.lastSize, + lastMove: quote.lastMove, + lastUpdatedAt: quote.lastUpdatedAt, + volume: quote.volume, + turnover: quote.turnover, + ...(shouldUpdateHistory || previousUpdate?.history + ? { history: [...quote.history] } + : {}), + }) + this.#eventIndex++ + } + + return [...updatedQuotes.values()] + } + + #applyTick( + quote: MarketQuoteSnapshot, + updateHistory: boolean, + updatedAt: number, + ): void { + const previousPrice = quote.price + const volatility = 0.00015 + this.#random() * 0.0012 + const move = previousPrice * (this.#random() - 0.495) * volatility + const nextPrice = roundPrice(Math.max(0.1, previousPrice + move)) + const spread = Math.max( + 0.01, + nextPrice * (0.00015 + this.#random() * 0.0005), + ) + + quote.lastMove = nextPrice - previousPrice + quote.price = nextPrice + quote.bid = roundPrice(nextPrice - spread / 2) + quote.ask = roundPrice(nextPrice + spread / 2) + quote.bidSize = Math.floor(100 + this.#random() * 25_000) + quote.askSize = Math.floor(100 + this.#random() * 25_000) + quote.lastSize = Math.floor(10 + this.#random() * 5_000) + quote.lastUpdatedAt = updatedAt + quote.volume += quote.lastSize + quote.turnover = roundMoney(quote.turnover + nextPrice * quote.lastSize) + + if (updateHistory) { + quote.history = [...quote.history.slice(-23), nextPrice] + } + } +} + +function createRandom(seed: number): () => number { + const runtime = { state: seed >>> 0 } + return () => { + runtime.state += 0x6d2b79f5 + const stateValue = runtime.state + const firstMix = Math.imul(stateValue ^ (stateValue >>> 15), stateValue | 1) + const secondMix = + firstMix + Math.imul(firstMix ^ (firstMix >>> 7), firstMix | 61) + const value = firstMix ^ secondMix + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296 + } +} + +function roundPrice(value: number): number { + return Math.round(value * 100) / 100 +} + +function roundMoney(value: number): number { + return Math.round(value * 100) / 100 +} diff --git a/examples/solid/realtime-trading/src/market-feed-protocol.ts b/examples/solid/realtime-trading/src/market-feed-protocol.ts new file mode 100644 index 0000000000..0a8b9925f5 --- /dev/null +++ b/examples/solid/realtime-trading/src/market-feed-protocol.ts @@ -0,0 +1,66 @@ +export interface MarketQuoteSnapshot { + id: string + symbol: string + company: string + venue: string + open: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + volume: number + turnover: number + history: Array +} + +export interface MarketQuoteUpdate { + index: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + volume: number + turnover: number + history?: Array +} + +export type MarketFeedCommand = + | { + type: 'initialize' + rowCount: number + seed: number + running: boolean + targetEventsPerSecond: number + updateSparklines: boolean + } + | { + type: 'configure' + running?: boolean + targetEventsPerSecond?: number + updateSparklines?: boolean + } + | { type: 'reset'; rowCount: number; seed: number } + | { type: 'burst'; eventCount: number } + | { type: 'ack'; generation: number; sequence: number } + +export type MarketFeedEvent = + | { + type: 'ready' + generation: number + quotes: Array + } + | { + type: 'batch' + generation: number + sequence: number + eventCount: number + updates: Array + } diff --git a/examples/solid/realtime-trading/src/market-feed.worker.ts b/examples/solid/realtime-trading/src/market-feed.worker.ts new file mode 100644 index 0000000000..7ce6313798 --- /dev/null +++ b/examples/solid/realtime-trading/src/market-feed.worker.ts @@ -0,0 +1,133 @@ +import { MarketFeedEngine } from './market-feed-engine' +import type { + MarketFeedCommand, + MarketFeedEvent, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const engine = new MarketFeedEngine() +const pendingUpdates = new Map() + +const runtime = { + generation: 0, + sequence: 0, + inFlightSequence: null as number | null, + pendingEventCount: 0, + initialized: false, + running: true, + targetEventsPerSecond: 10_000, + updateSparklines: true, + eventBudget: 0, + lastTickAt: performance.now(), +} + +addEventListener('message', ({ data }: MessageEvent) => { + switch (data.type) { + case 'initialize': + runtime.running = data.running + runtime.targetEventsPerSecond = data.targetEventsPerSecond + runtime.updateSparklines = data.updateSparklines + reset(data.rowCount, data.seed) + break + case 'configure': + runtime.running = data.running ?? runtime.running + runtime.targetEventsPerSecond = + data.targetEventsPerSecond ?? runtime.targetEventsPerSecond + runtime.updateSparklines = + data.updateSparklines ?? runtime.updateSparklines + break + case 'reset': + reset(data.rowCount, data.seed) + break + case 'burst': + produceEvents(data.eventCount) + flush() + break + case 'ack': + if ( + data.generation === runtime.generation && + data.sequence === runtime.inFlightSequence + ) { + runtime.inFlightSequence = null + flush() + } + break + } +}) + +setInterval(() => { + const now = performance.now() + const elapsed = Math.min(100, Math.max(0, now - runtime.lastTickAt)) + runtime.lastTickAt = now + + if (runtime.initialized && runtime.running) { + runtime.eventBudget += + (runtime.targetEventsPerSecond * elapsed) / 1_000 + const eventCount = Math.floor(runtime.eventBudget) + runtime.eventBudget -= eventCount + produceEvents(eventCount) + } + + flush() +}, 16) + +function reset(rowCount: number, seed: number): void { + runtime.initialized = true + runtime.generation++ + runtime.sequence = 0 + runtime.inFlightSequence = null + runtime.pendingEventCount = 0 + pendingUpdates.clear() + runtime.eventBudget = 0 + runtime.lastTickAt = performance.now() + + post({ + type: 'ready', + generation: runtime.generation, + quotes: engine.reset(rowCount, seed), + }) +} + +function produceEvents(eventCount: number): void { + if (!runtime.initialized || eventCount <= 0) return + + runtime.pendingEventCount += eventCount + for (const update of engine.applyEvents( + eventCount, + runtime.updateSparklines, + )) { + const previousUpdate = pendingUpdates.get(update.index) + pendingUpdates.set(update.index, { + ...update, + ...(update.history || !previousUpdate?.history + ? {} + : { history: previousUpdate.history }), + }) + } +} + +function flush(): void { + if ( + runtime.inFlightSequence !== null || + runtime.pendingEventCount === 0 + ) + return + + const nextSequence = ++runtime.sequence + const message: MarketFeedEvent = { + type: 'batch', + generation: runtime.generation, + sequence: nextSequence, + eventCount: runtime.pendingEventCount, + updates: [...pendingUpdates.values()], + } + + runtime.pendingEventCount = 0 + pendingUpdates.clear() + runtime.inFlightSequence = nextSequence + post(message) +} + +function post(event: MarketFeedEvent): void { + postMessage(event) +} diff --git a/examples/solid/realtime-trading/src/quote-cells.tsx b/examples/solid/realtime-trading/src/quote-cells.tsx new file mode 100644 index 0000000000..f797042441 --- /dev/null +++ b/examples/solid/realtime-trading/src/quote-cells.tsx @@ -0,0 +1,201 @@ +import { onCleanup, onMount } from 'solid-js' + +const compactNumber = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +export const quoteCellLifecycle = { + created: 0, + destroyed: 0, +} + +export const quoteCellRendererNames = [ + 'Ticker', + 'Venue', + 'Bid', + 'Ask', + 'Spread', + 'Last', + 'LastMove', + 'LastQty', + 'Depth', + 'QuoteAge', + 'DayChange', + 'TotalQty', + 'TradedValue', + 'Intraday', +] as const + +export const quoteComponentNames = [ + 'PriceCell', + 'StableMoveCell', + 'UpMoveCell', + 'DownMoveCell', + 'SpreadCell', + 'DepthCell', + 'QuoteAgeCell', + 'SparklineCell', +] as const + +export type QuoteCellRendererName = (typeof quoteCellRendererNames)[number] +export type QuoteComponentName = (typeof quoteComponentNames)[number] + +const createCounterMap = ( + names: ReadonlyArray, +): Record => + Object.fromEntries(names.map((name) => [name, 0])) as Record + +export const quoteRenderDiagnostics = { + cellRendererCalls: 0, + componentRenderCalls: 0, + cellRendererCallsByName: createCounterMap(quoteCellRendererNames), + componentRenderCallsByName: createCounterMap(quoteComponentNames), +} + +export function recordCellRender( + name: QuoteCellRendererName, + value: () => T, +): T { + quoteRenderDiagnostics.cellRendererCalls++ + quoteRenderDiagnostics.cellRendererCallsByName[name]++ + return value() +} + +function trackLifecycle(componentName: QuoteComponentName): void { + quoteRenderDiagnostics.componentRenderCalls++ + quoteRenderDiagnostics.componentRenderCallsByName[componentName]++ + onMount(() => { + quoteCellLifecycle.created++ + }) + onCleanup(() => { + quoteCellLifecycle.destroyed++ + }) +} + +export function PriceCell(props: { + price: number + move: number + onSelect: () => void +}) { + trackLifecycle('PriceCell') + return ( + + ) +} + +export function StableMoveCell(props: { move: number }) { + trackLifecycle('StableMoveCell') + return ( + = 0, + 'quote-down': props.move < 0, + }} + > + {formatSigned(props.move)} + + ) +} + +export function UpMoveCell(props: { move: number }) { + trackLifecycle('UpMoveCell') + return โ–ฒ {formatSigned(props.move)} +} + +export function DownMoveCell(props: { move: number }) { + trackLifecycle('DownMoveCell') + return โ–ผ {formatSigned(props.move)} +} + +export function SpreadCell(props: { bid: number; ask: number }) { + trackLifecycle('SpreadCell') + const spread = () => Math.max(0, props.ask - props.bid) + const basisPoints = () => { + const midpoint = (props.bid + props.ask) / 2 + return midpoint === 0 ? 0 : (spread() / midpoint) * 10_000 + } + + return ( + = 4 }}> + {spread().toFixed(2)} + {basisPoints().toFixed(1)} bp + + ) +} + +export function DepthCell(props: { bidSize: number; askSize: number }) { + trackLifecycle('DepthCell') + const bidShare = () => { + const total = props.bidSize + props.askSize + return total === 0 ? 50 : (props.bidSize / total) * 100 + } + + return ( +
+ + + + {compactNumber.format(props.bidSize)} + {compactNumber.format(props.askSize)} + +
+ ) +} + +export function QuoteAgeCell(props: { ageMs: number }) { + trackLifecycle('QuoteAgeCell') + return ( + = 500, + 'quote-age-stale': props.ageMs >= 1_500, + }} + > + {props.ageMs < 1_000 + ? `${Math.round(props.ageMs)} ms` + : `${(props.ageMs / 1_000).toFixed(1)} s`} + + ) +} + +export function SparklineCell(props: { values: ReadonlyArray }) { + trackLifecycle('SparklineCell') + const points = () => { + const min = Math.min(...props.values) + const max = Math.max(...props.values) + const range = max - min || 1 + const denominator = Math.max(1, props.values.length - 1) + return props.values + .map((value, index) => { + const x = (index / denominator) * 100 + const y = 22 - ((value - min) / range) * 20 + return `${x.toFixed(1)},${y.toFixed(1)}` + }) + .join(' ') + } + + return ( + + + + ) +} + +function formatSigned(value: number): string { + return `${value >= 0 ? '+' : ''}${value.toFixed(2)}` +} diff --git a/examples/solid/realtime-trading/src/shell/TradingShell.tsx b/examples/solid/realtime-trading/src/shell/TradingShell.tsx new file mode 100644 index 0000000000..c312b11b4b --- /dev/null +++ b/examples/solid/realtime-trading/src/shell/TradingShell.tsx @@ -0,0 +1,574 @@ +import { Show } from 'solid-js' +import { longAnimationFramesSupported } from '../benchmark/benchmark-monitor' +import { useTradingShellController } from './trading-shell-context' +import type { JSX } from 'solid-js' +import type { FeedMetrics } from '../benchmark/benchmark-monitor' +import type { FeedLoadProfile, RowWorkloadMode } from '../benchmark-profiles' +import type { TableAdapter } from '../trading-table' + +const integerFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 0, +}) +const rateFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) +export function TradingShell(props: { children: JSX.Element }) { + return ( +
+ + + + + + +
+
+ + + {props.children} + +
+ +
+
+ ) +} + +function AppHeader() { + const { workerReady, running } = useTradingShellController().state + return ( +
+
+ TT + MARKET MONITOR + SIMULATED +
+
+ SOLID / FLEX RENDER + + +
+
+ ) +} + +function MarketToolbar() { + const { + rendererMode, + updateSparklines, + updateQuoteAges, + quotes, + displayQuotes, + adapterLabel, + workloadLabel, + } = useTradingShellController().state + return ( +
+
+ WATCHLIST + ALL INSTRUMENTS +
+
+ + {formatInteger(displayQuotes().length)} /{' '} + {formatInteger(quotes().length)} SYMBOLS + + SOLID {adapterLabel()} + WORKER STREAM + IMMUTABLE ROWS + {workloadLabel()} + + {rendererMode() === 'stable' ? 'STABLE CELLS' : 'A/B CELL SWAP'} + + {updateSparklines() ? 'CHARTS ON' : 'CHARTS OFF'} + {updateQuoteAges() ? 'AGE CLOCK ON' : 'AGE CLOCK OFF'} +
+
+ ) +} + +function MarketStatusbar() { + const { metrics, mountedCells, liveComponents } = + useTradingShellController().state + return ( +
+ + BATCH EVENTS {formatInteger(metrics().lastBatchSize)} + + + ROW UPDATES {formatInteger(metrics().lastUpdateCount)} + + + HOSTS {formatInteger(mountedCells())} + + + COMPONENTS {formatInteger(liveComponents())} + + + WORKER / ACKNOWLEDGED / IMMUTABLE +
+ ) +} + +function Configurator() { + const { state, actions } = useTradingShellController() + const { + running, + tableAdapter, + instrumentCount, + feedLoadProfile, + targetEventsPerSecond, + rowWorkloadMode, + rendererMode, + updateSparklines, + updateQuoteAges, + metrics, + selectedQuote, + mountedCells, + liveComponents, + } = state + const { + toggleFeed, + setInstrumentCount, + setFeedLoadProfile, + setTargetEventsPerSecond, + setRowWorkloadMode, + setTableAdapter, + setRendererMode, + setUpdateSparklines, + setUpdateQuoteAges, + runBurst, + resetMarket, + } = actions + return ( + + ) +} + +function MetricsStrip() { + const { metrics } = useTradingShellController().state + return ( +
+
+ THROUGHPUT + + {formatRate(metrics().actualEventsPerSecond)} + + events/s +
+
+ RAF RATE + + {metrics().rafCallbacksPerSecond.toFixed(1)} + + callbacks/s +
+
+ TABLE RENDERS + + {metrics().tableRendersPerSecond.toFixed(1)} + + worker batches/s +
+
+ AVG RENDER + {formatMs(metrics().averageRenderMs)} + mutation โ†’ render +
+
+ P95 RENDER + {formatMs(metrics().p95RenderMs)} + max {formatMs(metrics().maxRenderMs)} +
+
+ LONG FRAMES + + N/A + unsupported + + } + > + 0, + }} + > + {metrics().longAnimationFrames} + + + worst {formatMs(metrics().worstLongAnimationFrameMs)} + + +
+
+ TOTAL EVENTS + + {formatInteger(metrics().totalEvents)} + + since reset +
+
+ ) +} + +function Diagnostics(props: { + metrics: FeedMetrics + mountedCells: number + liveComponents: number +}) { + return ( +
+

DIAGNOSTICS

+
+
+
Mounted cells
+
{formatInteger(props.mountedCells)}
+
+
+
Live components
+
{formatInteger(props.liveComponents)}
+
+
+
Created / destroyed
+
+ {formatInteger(props.metrics.componentsCreated)} /{' '} + {formatInteger(props.metrics.componentsDestroyed)} +
+
+
+
Cell renderer calls / s
+
+ {formatRate(props.metrics.cellRendererCallsPerSecond)} +
+
+
+
Component function calls / s
+
+ {formatRate(props.metrics.componentRenderCallsPerSecond)} +
+
+
+
Component function calls by type / s
+
+ {formatInvocationRates(props.metrics.componentRenderRates)} +
+
+
+
Cell callbacks by column / s
+
+ {formatInvocationRates(props.metrics.cellRendererRates)} +
+
+
+
DOM mutation records / s
+
+ {formatRate(props.metrics.domMutationsPerSecond)} +
+
+
+
Worker messages
+
+ {formatInteger(props.metrics.workerMessages)} +
+
+
+
Last batch events / rows
+
+ {formatInteger(props.metrics.lastBatchSize)} /{' '} + {formatInteger(props.metrics.lastUpdateCount)} +
+
+
+
Renders > 16.7 ms
+
{props.metrics.slowRenders}
+
+
+
Long animation frames
+
+ {longAnimationFramesSupported + ? formatInteger(props.metrics.longAnimationFrames) + : 'Unsupported'} +
+
+
+
JS heap
+
+ {props.metrics.heapMb === null + ? 'N/A' + : `${props.metrics.heapMb.toFixed(1)} MB`} +
+
+
+
+ ) +} + +function formatInteger(value: number): string { + return integerFormatter.format(value) +} + +function formatRate(value: number): string { + return rateFormatter.format(value) +} + +function formatMs(value: number): string { + return `${value.toFixed(2)} ms` +} + +function formatInvocationRates( + rates: FeedMetrics['componentRenderRates'], +): string { + const activeRates = rates + .filter((rate) => rate.callsPerSecond > 0) + .sort((left, right) => right.callsPerSecond - left.callsPerSecond) + return activeRates.length === 0 + ? 'No calls in sample' + : activeRates + .map( + (rate) => `${rate.name} ${formatRate(rate.callsPerSecond)}`, + ) + .join(' ยท ') +} diff --git a/examples/solid/realtime-trading/src/shell/trading-shell-context.tsx b/examples/solid/realtime-trading/src/shell/trading-shell-context.tsx new file mode 100644 index 0000000000..99b4794fdd --- /dev/null +++ b/examples/solid/realtime-trading/src/shell/trading-shell-context.tsx @@ -0,0 +1,26 @@ +import { createContext, useContext } from 'solid-js' +import type { JSX } from 'solid-js' +import type { TradingBenchmarkController } from '../core/trading-benchmark-controller' + +const TradingShellContext = createContext() + +export function TradingShellProvider(props: { + controller: TradingBenchmarkController + children: JSX.Element +}) { + return ( + + {props.children} + + ) +} + +export function useTradingShellController(): TradingBenchmarkController { + const controller = useContext(TradingShellContext) + if (!controller) { + throw new Error( + 'Trading shell components must be rendered inside TradingShellProvider', + ) + } + return controller +} diff --git a/examples/solid/realtime-trading/src/trading-table.tsx b/examples/solid/realtime-trading/src/trading-table.tsx new file mode 100644 index 0000000000..dda337e88e --- /dev/null +++ b/examples/solid/realtime-trading/src/trading-table.tsx @@ -0,0 +1,339 @@ +import { + createSolidTable, + flexRender as flexRenderV8, + getCoreRowModel, +} from '@tanstack/solid-table-v8' +import { FlexRender, createTable, stockFeatures } from '@tanstack/solid-table' +import { For, Show } from 'solid-js' +import { + DepthCell, + DownMoveCell, + PriceCell, + QuoteAgeCell, + SparklineCell, + SpreadCell, + StableMoveCell, + UpMoveCell, + recordCellRender, +} from './quote-cells' +import type { JSX } from 'solid-js' +import type { MarketQuote } from './market-data' + +export type RendererMode = 'stable' | 'swap' +export type TableAdapter = 'local' | 'v8' + +export interface TradingTableProps { + quotes: Array + rendererMode: RendererMode + updateQuoteAges: boolean + quoteClock: number + selectedSymbol: string | null + onSelectSymbol: (symbol: string) => void +} + +interface TradingCellContext { + row: { original: MarketQuote } +} + +interface TradingColumnDefinition { + id: string + header: string + size: number + cell: (context: TradingCellContext) => JSX.Element +} + +const compactFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) +const currencyFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + style: 'currency', + currency: 'USD', +}) + +function createTradingColumns( + props: TradingTableProps, +): Array { + return [ + { + id: 'symbol', + header: 'Ticker', + size: 90, + cell: ({ row }) => + recordCellRender('Ticker', () => row.original.symbol), + }, + { + id: 'venue', + header: 'Venue', + size: 70, + cell: ({ row }) => recordCellRender('Venue', () => row.original.venue), + }, + { + id: 'bid', + header: 'Bid', + size: 90, + cell: ({ row }) => + recordCellRender('Bid', () => row.original.bid.toFixed(2)), + }, + { + id: 'ask', + header: 'Ask', + size: 90, + cell: ({ row }) => + recordCellRender('Ask', () => row.original.ask.toFixed(2)), + }, + { + id: 'spread', + header: 'Spread', + size: 95, + cell: ({ row }) => + recordCellRender('Spread', () => ( + + )), + }, + { + id: 'price', + header: 'Last', + size: 100, + cell: ({ row }) => + recordCellRender('Last', () => ( + props.onSelectSymbol(row.original.symbol)} + /> + )), + }, + { + id: 'lastMove', + header: 'Last Move', + size: 105, + cell: ({ row }) => + recordCellRender('LastMove', () => { + const move = row.original.lastMove + if (props.rendererMode === 'stable') { + return + } + return move >= 0 ? ( + + ) : ( + + ) + }), + }, + { + id: 'lastSize', + header: 'Last Qty', + size: 90, + cell: ({ row }) => + recordCellRender('LastQty', () => + compactFormatter.format(row.original.lastSize), + ), + }, + { + id: 'depth', + header: 'Bid / Ask Qty', + size: 145, + cell: ({ row }) => + recordCellRender('Depth', () => ( + + )), + }, + { + id: 'age', + header: 'Quote Age', + size: 85, + cell: ({ row }) => + recordCellRender('QuoteAge', () => ( + + )), + }, + { + id: 'change', + header: 'Day %', + size: 90, + cell: ({ row }) => + recordCellRender('DayChange', () => { + const change = (row.original.price / row.original.open - 1) * 100 + return `${change >= 0 ? '+' : ''}${change.toFixed(2)}%` + }), + }, + { + id: 'volume', + header: 'Total Qty', + size: 100, + cell: ({ row }) => + recordCellRender('TotalQty', () => + compactFormatter.format(row.original.volume), + ), + }, + { + id: 'turnover', + header: 'Traded Value', + size: 115, + cell: ({ row }) => + recordCellRender('TradedValue', () => + currencyFormatter.format(row.original.turnover), + ), + }, + { + id: 'history', + header: 'Intraday', + size: 150, + cell: ({ row }) => + recordCellRender('Intraday', () => ( + + )), + }, + ] +} + +export function LocalTradingTable(props: TradingTableProps) { + const columns = createTradingColumns(props) + const table = createTable({ + key: 'solid-realtime-trading-local', + features: stockFeatures, + columns, + get data() { + return props.quotes + }, + getRowId: (row) => row.id, + }) + + return ( +
+ + + + {(headerGroup) => ( + + + {(header) => ( + + )} + + + )} + + + + + {(row) => ( + + + {(cell) => ( + + )} + + + )} + + +
+ + + +
+ +
+
+ ) +} + +export function V8TradingTable(props: TradingTableProps) { + const columns = createTradingColumns(props) + const table = createSolidTable({ + columns, + get data() { + return props.quotes + }, + getCoreRowModel: getCoreRowModel(), + getRowId: (row) => row.id, + }) + + return ( +
+ + + + {(headerGroup) => ( + + + {(header) => ( + + )} + + + )} + + + + + {(row) => ( + + + {(cell) => ( + + )} + + + )} + + +
+ + {flexRenderV8( + header.column.columnDef.header, + header.getContext(), + )} + +
+ {flexRenderV8( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+
+ ) +} + +export const TRADING_COLUMN_COUNT = 14 diff --git a/examples/solid/realtime-trading/src/vite-env.d.ts b/examples/solid/realtime-trading/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/solid/realtime-trading/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/solid/realtime-trading/tests/e2e/smoke.spec.ts b/examples/solid/realtime-trading/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000000..07452e8a0e --- /dev/null +++ b/examples/solid/realtime-trading/tests/e2e/smoke.spec.ts @@ -0,0 +1,104 @@ +import path from 'node:path' +import { expect, test } from '@playwright/test' +import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' + +const exampleDir = path.resolve() + +function collectPageErrors(page: Page) { + const errors: Array = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + return errors +} + +test('runs the same Solid workload across both table adapters', async ({ + page, +}) => { + const server = await startExampleServer(exampleDir) + const errors = collectPageErrors(page) + + try { + await page.goto(server.url) + + const table = page.getByRole('table') + await expect(table).toBeVisible() + await expect(table.locator('tbody tr')).toHaveCount(250) + await expect(table.locator('thead th')).toHaveCount(14) + await expect(table.locator('thead')).toContainText('Ticker') + await expect(table.locator('thead')).toContainText('Last Qty') + await expect(table.locator('thead')).toContainText('Traded Value') + const adapter = page.getByTestId('adapter-select') + await expect(adapter).toHaveValue('local') + await expect(page.getByTestId('feed-status')).toHaveText('FEED LIVE') + const instrumentCount = page.getByTestId('instrument-count-select') + await expect(instrumentCount.locator('option[value="150"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="350"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="750"]')).toHaveCount(1) + + const loadProfile = page.getByTestId('load-profile-select') + await expect(loadProfile).toHaveValue('high') + await loadProfile.selectOption('very-high') + await expect(page.getByTestId('target-rate-slider')).toHaveValue('25000') + await loadProfile.selectOption('high') + + const rowWorkload = page.getByTestId('row-workload-select') + await rowWorkload.selectOption('rotating-filter') + await expect(table.locator('tbody tr')).toHaveCount(200) + await rowWorkload.selectOption('identity-churn') + await expect(table.locator('tbody tr')).toHaveCount(250) + await expect( + table.locator('tbody tr[data-row-id*="-replacement-"]'), + ).toHaveCount(25) + await rowWorkload.selectOption('price-sort') + await expect(table.locator('tbody tr')).toHaveCount(250) + await rowWorkload.selectOption('stable') + + await expect + .poll(async () => { + const text = await page.getByTestId('total-events').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => { + const text = await page.getByTestId('worker-messages').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('raf-rate').textContent()), + ) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('table-render-rate').textContent()), + ) + .toBeGreaterThan(0) + + for (const implementation of ['v8', 'local']) { + await adapter.selectOption(implementation) + await expect(adapter).toHaveValue(implementation) + await expect(page.getByRole('table')).toBeVisible() + await expect(page.locator('tbody tr')).toHaveCount(250) + + const firstPrice = page.locator('tbody tr').first().getByRole('button') + const priceBeforeUpdate = await firstPrice.textContent() + await expect + .poll(() => firstPrice.textContent()) + .not.toBe(priceBeforeUpdate) + } + + await page.locator('.config-section input[type="checkbox"]').first().check() + await page.getByTestId('feed-toggle').click() + await expect(page.getByTestId('feed-toggle')).toHaveText('START FEED') + await expect(page.getByTestId('feed-status')).toHaveText('FEED PAUSED') + + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) diff --git a/examples/solid/realtime-trading/tsconfig.json b/examples/solid/realtime-trading/tsconfig.json new file mode 100644 index 0000000000..24795ad41a --- /dev/null +++ b/examples/solid/realtime-trading/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "tests/e2e", "vite.config.ts"] +} diff --git a/examples/solid/realtime-trading/vite.config.ts b/examples/solid/realtime-trading/vite.config.ts new file mode 100644 index 0000000000..eb6fcca05e --- /dev/null +++ b/examples/solid/realtime-trading/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import solidPlugin from 'vite-plugin-solid' + +export default defineConfig({ + server: { + port: 7779, + allowedHosts: true, + }, + plugins: [solidPlugin()], + build: { + target: 'esnext', + }, +}) diff --git a/package.json b/package.json index 75da2ae0c9..23e1743677 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,12 @@ "overrides": { "@tanstack/alpine-table": "workspace:*", "@tanstack/angular-table": "workspace:*", + "@tanstack/angular-table@8.21.4>@tanstack/table-core": "8.21.3", + "@tanstack/angular-table@9.0.0-beta.80>@tanstack/table-core": "9.0.0-beta.80", + "@tanstack/react-table@8.21.3>@tanstack/table-core": "8.21.3", + "@tanstack/react-table@9.0.0-beta.80>@tanstack/table-core": "9.0.0-beta.80", + "@tanstack/solid-table@8.21.3>@tanstack/table-core": "8.21.3", + "@tanstack/solid-table@9.0.0-beta.80>@tanstack/table-core": "9.0.0-beta.80", "@tanstack/angular-table-devtools": "workspace:*", "@tanstack/ember-table": "workspace:*", "@tanstack/lit-table": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62d2e470ee..c787e80224 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,12 @@ overrides: '@tanstack/alpine-table': workspace:* '@tanstack/angular-table-devtools': workspace:* '@tanstack/angular-table': workspace:* + '@tanstack/angular-table@8.21.4>@tanstack/table-core': 8.21.3 + '@tanstack/angular-table@9.0.0-beta.80>@tanstack/table-core': 9.0.0-beta.80 + '@tanstack/react-table@8.21.3>@tanstack/table-core': 8.21.3 + '@tanstack/react-table@9.0.0-beta.80>@tanstack/table-core': 9.0.0-beta.80 + '@tanstack/solid-table@8.21.3>@tanstack/table-core': 8.21.3 + '@tanstack/solid-table@9.0.0-beta.80>@tanstack/table-core': 9.0.0-beta.80 '@tanstack/ember-table': workspace:* '@tanstack/lit-table': workspace:* '@tanstack/match-sorter-utils': workspace:* @@ -2266,6 +2272,49 @@ importers: specifier: 6.0.3 version: 6.0.3 + examples/angular/realtime-trading: + dependencies: + '@angular/common': + specifier: ^22.1.0 + version: 22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2))(rxjs@7.8.2) + '@angular/compiler': + specifier: ^22.1.0 + version: 22.1.0 + '@angular/core': + specifier: ^22.1.0 + version: 22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2) + '@angular/platform-browser': + specifier: ^22.1.0 + version: 22.1.0(@angular/animations@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)))(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)) + '@tanstack/angular-table': + specifier: workspace:* + version: link:../../../packages/angular-table + '@tanstack/angular-table-beta': + specifier: https://registry.npmjs.org/@tanstack/angular-table/-/angular-table-9.0.0-beta.80.tgz + version: '@tanstack/angular-table@9.0.0-beta.80(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2))' + '@tanstack/angular-table-v8': + specifier: https://registry.npmjs.org/@tanstack/angular-table/-/angular-table-8.21.4.tgz + version: '@tanstack/angular-table@8.21.4(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2))' + rxjs: + specifier: ~7.8.2 + version: 7.8.2 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + devDependencies: + '@angular/build': + specifier: ^22.1.2 + version: 22.1.2(0935b688b04d27585268941dfe103369) + '@angular/cli': + specifier: ^22.1.2 + version: 22.1.2(@types/node@26.1.2)(chokidar@5.0.0)(supports-color@8.1.1) + '@angular/compiler-cli': + specifier: ^22.1.0 + version: 22.1.0(@angular/compiler@22.1.0)(typescript@6.0.3) + typescript: + specifier: 6.0.3 + version: 6.0.3 + examples/angular/remote-data: dependencies: '@angular/common': @@ -10214,6 +10263,49 @@ importers: specifier: ^8.2.0 version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.25))(terser@5.49.0)(yaml@2.9.0) + examples/react/realtime-trading: + dependencies: + '@tanstack/react-store': + specifier: ^0.11.0 + version: 0.11.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/react-table': + specifier: workspace:* + version: link:../../../packages/react-table + '@tanstack/react-table-v8': + specifier: https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz + version: '@tanstack/react-table@8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + devDependencies: + '@rolldown/plugin-babel': + specifier: ^0.2.3 + version: 0.2.3(@babel/core@8.0.1)(@babel/plugin-transform-runtime@8.0.1(@babel/core@8.0.1))(@babel/runtime@8.0.0)(rolldown@1.2.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.25))(terser@5.49.0)(yaml@2.9.0)) + '@rollup/plugin-replace': + specifier: ^6.0.3 + version: 6.0.3(rollup@4.62.3) + '@types/react': + specifier: 19.2.16 + version: 19.2.16 + '@types/react-dom': + specifier: ^19.2.4 + version: 19.2.4(@types/react@19.2.16) + '@vitejs/plugin-react': + specifier: ^6.0.5 + version: 6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@8.0.1)(@babel/plugin-transform-runtime@8.0.1(@babel/core@8.0.1))(@babel/runtime@8.0.0)(rolldown@1.2.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.25))(terser@5.49.0)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.25))(terser@5.49.0)(yaml@2.9.0)) + babel-plugin-react-compiler: + specifier: ^1.0.0 + version: 1.0.0 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: ^8.2.0 + version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.25))(terser@5.49.0)(yaml@2.9.0) + examples/react/row-dnd: dependencies: '@dnd-kit/core': @@ -11573,6 +11665,28 @@ importers: specifier: ^2.11.14 version: 2.11.14(@testing-library/jest-dom@7.0.0(@testing-library/dom@10.4.1))(solid-js@1.9.14)(supports-color@8.1.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.25))(terser@5.49.0)(yaml@2.9.0)) + examples/solid/realtime-trading: + dependencies: + '@tanstack/solid-table': + specifier: workspace:* + version: link:../../../packages/solid-table + '@tanstack/solid-table-v8': + specifier: https://registry.npmjs.org/@tanstack/solid-table/-/solid-table-8.21.3.tgz + version: '@tanstack/solid-table@8.21.3(solid-js@1.9.14)' + solid-js: + specifier: ^1.9.14 + version: 1.9.14 + devDependencies: + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: ^8.2.0 + version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.25))(terser@5.49.0)(yaml@2.9.0) + vite-plugin-solid: + specifier: ^2.11.14 + version: 2.11.14(@testing-library/jest-dom@7.0.0(@testing-library/dom@10.4.1))(solid-js@1.9.14)(supports-color@8.1.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.25))(terser@5.49.0)(yaml@2.9.0)) + examples/solid/row-pinning: dependencies: '@tanstack/solid-table': @@ -17661,6 +17775,7 @@ packages: '@ngtools/webpack@22.1.2': resolution: {integrity: sha512-5o3zbOdEHv5+wjRCMN+t6RBou28dbPkio5/rxEJBsQ9iEq11K+atsSKEaZXBMUhYr9nLYR8Pckm5Cqx2FP9R2Q==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + deprecated: Angular's Webpack support is deprecated. Use the esbuild and Vite-based "@angular/build" package instead. peerDependencies: '@angular/compiler-cli': ^22.0.0 typescript: '>=6.0 <6.1' @@ -20366,6 +20481,18 @@ packages: '@angular/common': '>=19.0.0' '@angular/core': '>=19.0.0' + '@tanstack/angular-table@8.21.4': + resolution: {integrity: sha512-djyuJFvIB/pBuBIqny8b7+5ozeXJSElaxYRZyyKLpYrXn4xQSysBT//X51eDT/mi3kS0spcjIHhoObjk3iCjcQ==} + engines: {node: '>=12'} + peerDependencies: + '@angular/core': '>=17' + + '@tanstack/angular-table@9.0.0-beta.80': + resolution: {integrity: sha512-ILLMlF4tiXU7bvdX+pkmVZFIl1nbZmGrm3KgUxIrNAH71W/QnwMo4hYHzh3ZDDBH/ZmOH5pfUEPPKkb5GWtTNw==} + engines: {node: '>=20'} + peerDependencies: + '@angular/core': '>=19' + '@tanstack/angular-virtual@6.0.2': resolution: {integrity: sha512-y/u2ZKAN5ob+ljwUoSK+r2j2piXw/ol63ykFbzZ8Hahj0dRU9xCJ0cJIDxTQ9zRoGbk0ZIiy3BK5w8Mso1daKA==} peerDependencies: @@ -20693,6 +20820,13 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + '@tanstack/react-virtual@3.14.9': resolution: {integrity: sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==} peerDependencies: @@ -20810,6 +20944,12 @@ packages: peerDependencies: solid-js: ^1.6.0 + '@tanstack/solid-table@8.21.3': + resolution: {integrity: sha512-PmhfSLBxVKiFs01LtYOYrCRhCyTUjxmb4KlxRQiqcALtip8+DOJeeezQM4RSX/GUS0SMVHyH/dNboCpcO++k2A==} + engines: {node: '>=12'} + peerDependencies: + solid-js: '>=1.3' + '@tanstack/solid-virtual@3.13.36': resolution: {integrity: sha512-9x4Eg3M+t7cY0/lswB6JnK1C/3YcsdBttUYQgSre+5zcwVJMZnp0Gy3mT++jeSN1bi7M5pxUPnAVoD1YE9DuLQ==} peerDependencies: @@ -20874,6 +21014,14 @@ packages: peerDependencies: svelte: ^3.48.0 || ^4.0.0 || ^5.0.0 + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + + '@tanstack/table-core@9.0.0-beta.80': + resolution: {integrity: sha512-tD98Q6/qzjvbEomMtDqxrAQUAf9nsBD7RihY1073E0eaeYwP3GyW5Gdlb+PztSk9F0CsYArXeOqcrt1hV29O8w==} + engines: {node: '>=20'} + '@tanstack/typedoc-config@0.3.3': resolution: {integrity: sha512-wVT2YfKDSpd+4f7fk6UaPIP3a2J7LSovlyVuFF1PH2yQb7gjqehod5zdFiwFyEXgvI9XGuFvvs1OehkKNYcr6A==} engines: {node: '>=18'} @@ -34163,6 +34311,21 @@ snapshots: '@tanstack/store': 0.9.3 tslib: 2.8.1 + '@tanstack/angular-table@8.21.4(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2))': + dependencies: + '@angular/core': 22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2) + '@tanstack/table-core': 8.21.3 + tslib: 2.8.1 + + '@tanstack/angular-table@9.0.0-beta.80(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2))': + dependencies: + '@angular/core': 22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2) + '@tanstack/angular-store': 0.11.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)) + '@tanstack/table-core': 9.0.0-beta.80 + tslib: 2.8.1 + transitivePeerDependencies: + - '@angular/common' + '@tanstack/angular-virtual@6.0.2(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2))': dependencies: '@angular/core': 22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2) @@ -34550,6 +34713,12 @@ snapshots: react-dom: 19.2.8(react@19.2.8) use-sync-external-store: 1.6.0(react@19.2.8) + '@tanstack/react-table@8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + '@tanstack/react-virtual@3.14.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@tanstack/virtual-core': 3.17.7 @@ -34716,6 +34885,11 @@ snapshots: '@tanstack/store': 0.11.0 solid-js: 1.9.14 + '@tanstack/solid-table@8.21.3(solid-js@1.9.14)': + dependencies: + '@tanstack/table-core': 8.21.3 + solid-js: 1.9.14 + '@tanstack/solid-virtual@3.13.36(solid-js@1.9.14)': dependencies: '@tanstack/virtual-core': 3.17.7 @@ -34807,6 +34981,12 @@ snapshots: '@tanstack/virtual-core': 3.17.7 svelte: 5.56.8(@typescript-eslint/types@8.65.0) + '@tanstack/table-core@8.21.3': {} + + '@tanstack/table-core@9.0.0-beta.80': + dependencies: + '@tanstack/store': 0.11.0 + '@tanstack/typedoc-config@0.3.3(typescript@6.0.3)': dependencies: typedoc: 0.28.14(typescript@6.0.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2b3d1e2913..daa834bc6e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,12 @@ overrides: '@tanstack/alpine-table': 'workspace:*' '@tanstack/angular-table-devtools': 'workspace:*' '@tanstack/angular-table': 'workspace:*' + '@tanstack/angular-table@8.21.4>@tanstack/table-core': 8.21.3 + '@tanstack/angular-table@9.0.0-beta.80>@tanstack/table-core': 9.0.0-beta.80 + '@tanstack/react-table@8.21.3>@tanstack/table-core': 8.21.3 + '@tanstack/react-table@9.0.0-beta.80>@tanstack/table-core': 9.0.0-beta.80 + '@tanstack/solid-table@8.21.3>@tanstack/table-core': 8.21.3 + '@tanstack/solid-table@9.0.0-beta.80>@tanstack/table-core': 9.0.0-beta.80 '@tanstack/ember-table': 'workspace:*' '@tanstack/lit-table': 'workspace:*' '@tanstack/match-sorter-utils': 'workspace:*' From 3977cc439ad204e72ccf556fa1579ae8c5c44c99 Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Fri, 7 Aug 2026 01:25:53 +0200 Subject: [PATCH 03/23] perf: revisit angular adapter to improve memory usage and flexrender dirty checking --- packages/angular-table/package.json | 1 + .../angular-table/src/flex-render/flags.ts | 29 +- .../src/flex-render/flexRenderComponent.ts | 110 +++-- .../flex-render/flexRenderComponentFactory.ts | 174 ++++---- .../angular-table/src/flex-render/renderer.ts | 208 +++++----- .../angular-table/src/flex-render/view.ts | 56 +-- .../src/helpers/flexRenderCell.ts | 7 +- packages/angular-table/src/injectTable.ts | 5 +- packages/angular-table/src/reactivity.ts | 8 +- .../flex-render-component.test-d.ts | 6 + .../tests/flex-render/flex-render.bench.ts | 239 +++++++++++ .../flex-render/flex-render.unit.test.ts | 388 +++++++++++++++++- .../angular-table/tests/injectTable.test.ts | 15 + 13 files changed, 963 insertions(+), 283 deletions(-) create mode 100644 packages/angular-table/tests/flex-render/flex-render.bench.ts diff --git a/packages/angular-table/package.json b/packages/angular-table/package.json index c902d00f7c..00c0634c78 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..2764bc2d3b 100644 --- a/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts +++ b/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts @@ -3,13 +3,12 @@ import { ComponentRef, Injectable, Injector, - KeyValueDiffer, - KeyValueDiffers, OutputEmitterRef, OutputRefSubscription, ViewContainerRef, } from '@angular/core' -import { FlexRenderComponent } from './flexRenderComponent' +import { hasOwn } from '@tanstack/table-core' +import type { FlexRenderComponent } from './flexRenderComponent' /** * Creates and manages Angular component instances used by flex-rendered table @@ -32,7 +31,7 @@ export class FlexRenderComponentFactory { { injector: componentInjector, directives: flexRenderComponent.directives, - bindings: flexRenderComponent.bindings ?? [], + bindings: flexRenderComponent.bindings, }, ) const view = new FlexRenderComponentRef( @@ -57,10 +56,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 +66,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 +83,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 +91,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 } @@ -159,9 +118,9 @@ export class FlexRenderComponentRef { } 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( @@ -177,75 +136,102 @@ export class FlexRenderComponentRef { } 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 in newInputs) { + if (hasOwn(newInputs, prop)) { + 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 in outputs) { + 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..5853d3c2ea 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,61 @@ export class FlexViewRenderer< } mount(): EffectRef { - let previousContent: FlexRenderInputContent - let previousProps: TProps + if (this.#outerRenderEffectRef) { + return this.#outerRenderEffectRef + } - return effect(() => { - const props = this.#props() - const content = this.#content() + let previousContent: FlexRenderInputContent | undefined + let previousProps: TProps | undefined - if (!(this.#renderFlags & FlexRenderFlags.ViewFirstRender)) { - if (previousContent !== content) { - this.#renderFlags |= FlexRenderFlags.ContentChanged - } - if (previousProps !== props) { - this.#renderFlags |= FlexRenderFlags.PropsReferenceChanged + this.#outerRenderEffectRef = effect( + () => { + const props = this.#props() + const content = this.#content() + + if (!(this.#renderFlags & FlexRenderFlags.ViewFirstRender)) { + if (previousContent !== content) { + this.#renderFlags |= FlexRenderFlags.ContentChanged + } + if (previousProps !== props) { + this.#renderFlags |= FlexRenderFlags.PropsReferenceChanged + } } - } - untracked(() => this.#update()) + 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,115 +213,120 @@ 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 { + // Recreating a view also recreates its render-function effect. Its first + // execution only records dependencies; later executions schedule updates. + if (this.#shouldRecreateEntireView()) { + this.#destroyContentEffect() } - this.#viewContainerRef.clear() - if (this.#renderView) { - this.#renderView.unmount() - this.#renderView = null + this.#destroyView() + + this.#renderFlags &= + FlexRenderFlags.ViewFirstRender | FlexRenderFlags.RenderEffectChecked + + const content = this.#getContentValue() + if (content.kind !== 'null') { + const injector = this.#injector() + const parentInjector = + content.kind === 'flexRenderComponent' + ? (content.content.injector ?? injector) + : injector + this.#renderView = this.#renderViewByContent( + content, + this.#props(), + parentInjector, + ) } - this.#renderFlags = - (this.#renderFlags & FlexRenderFlags.ViewFirstRender) | - (this.#renderFlags & FlexRenderFlags.RenderEffectChecked) - - const resolvedContent = this.#getContentValue() - this.#renderView = this.#renderViewByContent(resolvedContent) - // If the content is a function `content(props)`, we initialize an effect - // to react to changes. If the current fn uses signals, we will set the DirtySignal flag - // to re-schedule the component updates + // 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 ( + #shouldRecreateEntireView(): boolean { + return !!( this.#renderFlags & - FlexRenderFlags.ContentChanged & - FlexRenderFlags.ViewFirstRender + (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, + content: Exclude, + props: TProps, + parentInjector: Injector, ): FlexRenderView | null { if (content.kind === 'primitive') { return this.#renderStringContent(content) } else if (content.kind === 'templateRef') { - return this.#renderTemplateRefContent(content) + return this.#renderTemplateRefContent(content, parentInjector) } else if (content.kind === 'flexRenderComponent') { - return this.#renderComponent(content) - } else if (content.kind === 'component') { - return this.#renderCustomComponent(content) - } else { - return null + return this.#renderComponent(content, parentInjector) } + return this.#renderCustomComponent(content, props, parentInjector) } #renderStringContent( template: Extract, ): FlexRenderTemplateView { - const context = () => { - const content = this.#content() - return typeof content === 'string' || typeof content === 'number' - ? content - : runInInjectionContext(this.#injector(), () => - content?.(this.#props()), - ) - } + const 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) @@ -313,16 +334,17 @@ export class FlexViewRenderer< #renderTemplateRefContent( template: Extract, + parentInjector: Injector, ): 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() }, + { injector: this.#getInjector(parentInjector) }, ) return new FlexRenderTemplateView(template, view) } @@ -332,9 +354,9 @@ export class FlexViewRenderer< FlexRenderTypedContent, { kind: 'flexRenderComponent' } >, + parentInjector: Injector, ): FlexRenderComponentView { - const { injector } = flexRenderComponent.content - const componentInjector = this.#getInjector(injector) + const componentInjector = this.#getInjector(parentInjector) const view = this.#flexRenderComponentFactory.createComponent( flexRenderComponent.content, componentInjector, @@ -344,11 +366,13 @@ export class FlexViewRenderer< #renderCustomComponent( component: Extract, + props: TProps, + parentInjector: Injector, ): FlexRenderComponentView { const instance = flexRenderComponent(component.content, { - inputs: this.#props(), + inputs: props, }) - const injector = this.#getInjector(instance.injector) + const injector = this.#getInjector(parentInjector) const view = this.#flexRenderComponentFactory.createComponent( instance, injector, @@ -356,7 +380,7 @@ export class FlexViewRenderer< return new FlexRenderComponentView(component, view) } - #getInjector(parentInjector?: Injector) { + #getInjector(parentInjector: Injector) { const getContext = () => this.#props() const proxy = new Proxy(this.#props(), { get: (_, key) => getContext()[key as keyof typeof _], @@ -383,7 +407,7 @@ export class FlexViewRenderer< } return Injector.create({ - parent: parentInjector ?? this.#injector(), + parent: parentInjector, providers: [ ...staticProviders, { provide: FlexRenderComponentProps, useValue: proxy }, diff --git a/packages/angular-table/src/flex-render/view.ts b/packages/angular-table/src/flex-render/view.ts index 39e93c2675..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/helpers/flexRenderCell.ts b/packages/angular-table/src/helpers/flexRenderCell.ts index 20f3a432f3..b96f02952d 100644 --- a/packages/angular-table/src/helpers/flexRenderCell.ts +++ b/packages/angular-table/src/helpers/flexRenderCell.ts @@ -130,12 +130,9 @@ export class FlexRenderCell< readonly #viewContainerRef = inject(ViewContainerRef) constructor() { - const content = computed(() => this.#renderData()[0]) - const props = computed(() => this.#renderData()[1]) - const renderer = new FlexViewRenderer({ - content: content, - props: props, + content: () => this.#renderData()[0], + props: () => this.#renderData()[1], injector: () => this.#injector, templateRef: this.#templateRef, viewContainerRef: this.#viewContainerRef, diff --git a/packages/angular-table/src/injectTable.ts b/packages/angular-table/src/injectTable.ts index 3486e93f7c..9cfeaca45a 100644 --- a/packages/angular-table/src/injectTable.ts +++ b/packages/angular-table/src/injectTable.ts @@ -99,14 +99,15 @@ export function injectTable< return ngZone.runOutsideAngular(() => lazyInit(() => { + const initialOptions = options() // Explicit type arguments skip generic inference from the spread object // (a type-check hot spot); the spread only adds the angular reactivity // binding to `features`. const table = constructTable({ - ...options(), + ...initialOptions, features: { coreReactivityFeature: angularReactivity(injector), - ...options().features, + ...initialOptions.features, }, }) diff --git a/packages/angular-table/src/reactivity.ts b/packages/angular-table/src/reactivity.ts index d35e0269ce..581e07f13e 100644 --- a/packages/angular-table/src/reactivity.ts +++ b/packages/angular-table/src/reactivity.ts @@ -21,9 +21,7 @@ function signalToReadonlyAtom( get: () => signal(), subscribe: (observer: Observer) => { return untracked(() => - toObservable(computed(signal), { injector: injector }).subscribe( - observer, - ), + toObservable(signal, { injector: injector }).subscribe(observer), ) }, }) @@ -47,9 +45,7 @@ function signalToWritableAtom( get: () => signal(), subscribe: (observer: Observer) => { return untracked(() => - toObservable(computed(signal), { injector: injector }).subscribe( - observer, - ), + toObservable(signal, { injector: injector }).subscribe(observer), ) }, }) diff --git a/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts b/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts index 698ec30ad7..638f90e400 100644 --- a/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts +++ b/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts @@ -12,4 +12,10 @@ test('Infer component inputs', () => { // Input is optional so we can skip passing the property flexRenderComponent(Test, { inputs: {} }) + + flexRenderComponent(Test, { key: 'stable-key' }) + flexRenderComponent(Test, { key: 1 }) + + // @ts-expect-error Keys must have stable primitive identity + flexRenderComponent(Test, { key: {} }) }) diff --git a/packages/angular-table/tests/flex-render/flex-render.bench.ts b/packages/angular-table/tests/flex-render/flex-render.bench.ts new file mode 100644 index 0000000000..76e8a02a1c --- /dev/null +++ b/packages/angular-table/tests/flex-render/flex-render.bench.ts @@ -0,0 +1,239 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, + signal, +} from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { bench, describe } from 'vitest' +import { + FlexRender, + flexRenderComponent, + injectTable, + stockFeatures, +} from '../../src' +import type { ColumnDef } from '../../src' + +const benchmarkOptions = { time: 2_000, warmupTime: 500 } + +@Component({ + template: ` + {{ tick() }} + @for (item of items; track item) { + + {{ value }} + + } + `, + imports: [FlexRender], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class PrimitiveTable { + readonly items = Array.from({ length: 500 }, (_, index) => index) + readonly value = signal('value') + readonly tick = signal(0) + readonly context = {} + readonly render = () => this.value() +} + +@Component({ + template: ``, +}) +class RenderedComponent {} + +describe('flexRender hot paths', () => { + const fixture = TestBed.createComponent(PrimitiveTable) + fixture.detectChanges() + + bench( + 'unrelated change detection for 500 primitive cells', + () => { + fixture.componentInstance.tick.update((value) => value + 1) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'create 500 component render descriptors', + () => { + for (let index = 0; index < 500; index++) { + flexRenderComponent(RenderedComponent) + } + }, + benchmarkOptions, + ) +}) + +interface BenchmarkRow { + id: string + values: Array +} + +const rowCount = 100 +const columnCount = 12 +const largeTableData: Array = Array.from( + { length: rowCount }, + (_, rowIndex) => ({ + id: `row-${rowIndex}`, + values: Array.from( + { length: columnCount }, + (_, columnIndex) => `${rowIndex}:${columnIndex}`, + ), + }), +) +const handleActivate = () => {} + +@Component({ + selector: 'benchmark-cell-a', + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class BenchmarkCellA { + readonly value = input.required() + readonly version = input.required() + readonly activate = output() +} + +@Component({ + selector: 'benchmark-cell-b', + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class BenchmarkCellB { + readonly value = input.required() + readonly version = input.required() + readonly activate = output() +} + +@Component({ + template: ` + {{ hostTick() }} + + + @for (row of table.getRowModel().rows; track row.id) { + + @for (cell of row.getVisibleCells(); track cell.id) { + + } + + } + +
+ + {{ value }} + +
+ `, + imports: [FlexRender], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class LargeMixedTable { + readonly hostTick = signal(0) + readonly valueVersion = signal(0) + readonly componentKind = signal<'a' | 'b'>('a') + readonly contentKind = signal<'primitive' | 'component'>('primitive') + + readonly columns: Array> = + Array.from({ length: columnCount }, (_, columnIndex) => ({ + id: `column-${columnIndex}`, + accessorFn: (row) => row.values[columnIndex]!, + cell: (context) => { + const value = context.getValue() + + // Four primitive columns whose values change in place. + if (columnIndex < 4) { + return `${value}:${this.valueVersion()}` + } + + // Four stable component columns whose inputs change frequently. + if (columnIndex < 8) { + const component = + columnIndex % 2 === 0 ? BenchmarkCellA : BenchmarkCellB + return flexRenderComponent(component, { + inputs: { value, version: this.valueVersion() }, + outputs: { activate: handleActivate }, + }) + } + + // Two columns that intentionally replace component A with component B. + if (columnIndex < 10) { + const component = + this.componentKind() === 'a' ? BenchmarkCellA : BenchmarkCellB + return flexRenderComponent(component, { + inputs: { value, version: 0 }, + outputs: { activate: handleActivate }, + }) + } + + // Two columns that cross the primitive/component view boundary. + return this.contentKind() === 'primitive' + ? value + : flexRenderComponent(BenchmarkCellA, { + inputs: { value, version: 0 }, + outputs: { activate: handleActivate }, + }) + }, + })) + + readonly table = injectTable(() => ({ + data: largeTableData, + columns: this.columns, + features: stockFeatures, + getRowId: (row) => row.id, + })) +} + +describe('flexRender large mixed table', () => { + const fixture = TestBed.createComponent(LargeMixedTable) + fixture.detectChanges() + + const instance = fixture.componentInstance + const renderedCellCount = fixture.nativeElement.querySelectorAll('td').length + if (renderedCellCount !== rowCount * columnCount) { + throw new Error(`Expected 1,200 cells, rendered ${renderedCellCount}`) + } + + bench( + 'unrelated host change with 1,200 mounted cells', + () => { + instance.hostTick.update((value) => value + 1) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'update 400 primitives and 400 stable component inputs', + () => { + instance.valueVersion.update((value) => value + 1) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'replace 200 component A/B cell views', + () => { + instance.componentKind.update((value) => (value === 'a' ? 'b' : 'a')) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'switch 200 cells between primitive and component views', + () => { + instance.contentKind.update((value) => + value === 'primitive' ? 'component' : 'primitive', + ) + fixture.detectChanges() + }, + benchmarkOptions, + ) +}) diff --git a/packages/angular-table/tests/flex-render/flex-render.unit.test.ts b/packages/angular-table/tests/flex-render/flex-render.unit.test.ts index 34c1c07797..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 aee244543d..20b84a7af9 100644 --- a/packages/angular-table/tests/injectTable.test.ts +++ b/packages/angular-table/tests/injectTable.test.ts @@ -17,6 +17,21 @@ import { injectTable } from '../src' import type { PaginationState } from '../src' describe('injectTable', () => { + test('evaluates options once while constructing the table', () => { + const options = vi.fn(() => ({ + data: [], + features: stockFeatures, + columns: [], + })) + const table = TestBed.runInInjectionContext(() => injectTable(options)) + + expect(options).not.toHaveBeenCalled() + + void table.options + + expect(options).toHaveBeenCalledTimes(1) + }) + test('should support required signal inputs', async () => { type Data = { id: string; title: string } From 9e871de15002ef37c74f6153fe8d14332552c35b Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Fri, 7 Aug 2026 01:25:53 +0200 Subject: [PATCH 04/23] perf: revisit angular adapter to improve memory usage and flexrender dirty checking --- packages/angular-table/package.json | 1 + .../angular-table/src/flex-render/flags.ts | 29 +- .../src/flex-render/flexRenderComponent.ts | 110 +++-- .../flex-render/flexRenderComponentFactory.ts | 174 ++++---- .../angular-table/src/flex-render/renderer.ts | 208 +++++----- .../angular-table/src/flex-render/view.ts | 56 +-- packages/angular-table/src/injectTable.ts | 5 +- packages/angular-table/src/reactivity.ts | 8 +- .../flex-render-component.test-d.ts | 6 + .../tests/flex-render/flex-render.bench.ts | 239 +++++++++++ .../flex-render/flex-render.unit.test.ts | 388 +++++++++++++++++- .../angular-table/tests/injectTable.test.ts | 15 + 12 files changed, 961 insertions(+), 278 deletions(-) create mode 100644 packages/angular-table/tests/flex-render/flex-render.bench.ts diff --git a/packages/angular-table/package.json b/packages/angular-table/package.json index c902d00f7c..00c0634c78 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..2764bc2d3b 100644 --- a/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts +++ b/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts @@ -3,13 +3,12 @@ import { ComponentRef, Injectable, Injector, - KeyValueDiffer, - KeyValueDiffers, OutputEmitterRef, OutputRefSubscription, ViewContainerRef, } from '@angular/core' -import { FlexRenderComponent } from './flexRenderComponent' +import { hasOwn } from '@tanstack/table-core' +import type { FlexRenderComponent } from './flexRenderComponent' /** * Creates and manages Angular component instances used by flex-rendered table @@ -32,7 +31,7 @@ export class FlexRenderComponentFactory { { injector: componentInjector, directives: flexRenderComponent.directives, - bindings: flexRenderComponent.bindings ?? [], + bindings: flexRenderComponent.bindings, }, ) const view = new FlexRenderComponentRef( @@ -57,10 +56,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 +66,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 +83,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 +91,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 } @@ -159,9 +118,9 @@ export class FlexRenderComponentRef { } 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( @@ -177,75 +136,102 @@ export class FlexRenderComponentRef { } 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 in newInputs) { + if (hasOwn(newInputs, prop)) { + 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 in outputs) { + 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..5853d3c2ea 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,61 @@ export class FlexViewRenderer< } mount(): EffectRef { - let previousContent: FlexRenderInputContent - let previousProps: TProps + if (this.#outerRenderEffectRef) { + return this.#outerRenderEffectRef + } - return effect(() => { - const props = this.#props() - const content = this.#content() + let previousContent: FlexRenderInputContent | undefined + let previousProps: TProps | undefined - if (!(this.#renderFlags & FlexRenderFlags.ViewFirstRender)) { - if (previousContent !== content) { - this.#renderFlags |= FlexRenderFlags.ContentChanged - } - if (previousProps !== props) { - this.#renderFlags |= FlexRenderFlags.PropsReferenceChanged + this.#outerRenderEffectRef = effect( + () => { + const props = this.#props() + const content = this.#content() + + if (!(this.#renderFlags & FlexRenderFlags.ViewFirstRender)) { + if (previousContent !== content) { + this.#renderFlags |= FlexRenderFlags.ContentChanged + } + if (previousProps !== props) { + this.#renderFlags |= FlexRenderFlags.PropsReferenceChanged + } } - } - untracked(() => this.#update()) + 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,115 +213,120 @@ 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 { + // Recreating a view also recreates its render-function effect. Its first + // execution only records dependencies; later executions schedule updates. + if (this.#shouldRecreateEntireView()) { + this.#destroyContentEffect() } - this.#viewContainerRef.clear() - if (this.#renderView) { - this.#renderView.unmount() - this.#renderView = null + this.#destroyView() + + this.#renderFlags &= + FlexRenderFlags.ViewFirstRender | FlexRenderFlags.RenderEffectChecked + + const content = this.#getContentValue() + if (content.kind !== 'null') { + const injector = this.#injector() + const parentInjector = + content.kind === 'flexRenderComponent' + ? (content.content.injector ?? injector) + : injector + this.#renderView = this.#renderViewByContent( + content, + this.#props(), + parentInjector, + ) } - this.#renderFlags = - (this.#renderFlags & FlexRenderFlags.ViewFirstRender) | - (this.#renderFlags & FlexRenderFlags.RenderEffectChecked) - - const resolvedContent = this.#getContentValue() - this.#renderView = this.#renderViewByContent(resolvedContent) - // If the content is a function `content(props)`, we initialize an effect - // to react to changes. If the current fn uses signals, we will set the DirtySignal flag - // to re-schedule the component updates + // 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 ( + #shouldRecreateEntireView(): boolean { + return !!( this.#renderFlags & - FlexRenderFlags.ContentChanged & - FlexRenderFlags.ViewFirstRender + (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, + content: Exclude, + props: TProps, + parentInjector: Injector, ): FlexRenderView | null { if (content.kind === 'primitive') { return this.#renderStringContent(content) } else if (content.kind === 'templateRef') { - return this.#renderTemplateRefContent(content) + return this.#renderTemplateRefContent(content, parentInjector) } else if (content.kind === 'flexRenderComponent') { - return this.#renderComponent(content) - } else if (content.kind === 'component') { - return this.#renderCustomComponent(content) - } else { - return null + return this.#renderComponent(content, parentInjector) } + return this.#renderCustomComponent(content, props, parentInjector) } #renderStringContent( template: Extract, ): FlexRenderTemplateView { - const context = () => { - const content = this.#content() - return typeof content === 'string' || typeof content === 'number' - ? content - : runInInjectionContext(this.#injector(), () => - content?.(this.#props()), - ) - } + const 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) @@ -313,16 +334,17 @@ export class FlexViewRenderer< #renderTemplateRefContent( template: Extract, + parentInjector: Injector, ): 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() }, + { injector: this.#getInjector(parentInjector) }, ) return new FlexRenderTemplateView(template, view) } @@ -332,9 +354,9 @@ export class FlexViewRenderer< FlexRenderTypedContent, { kind: 'flexRenderComponent' } >, + parentInjector: Injector, ): FlexRenderComponentView { - const { injector } = flexRenderComponent.content - const componentInjector = this.#getInjector(injector) + const componentInjector = this.#getInjector(parentInjector) const view = this.#flexRenderComponentFactory.createComponent( flexRenderComponent.content, componentInjector, @@ -344,11 +366,13 @@ export class FlexViewRenderer< #renderCustomComponent( component: Extract, + props: TProps, + parentInjector: Injector, ): FlexRenderComponentView { const instance = flexRenderComponent(component.content, { - inputs: this.#props(), + inputs: props, }) - const injector = this.#getInjector(instance.injector) + const injector = this.#getInjector(parentInjector) const view = this.#flexRenderComponentFactory.createComponent( instance, injector, @@ -356,7 +380,7 @@ export class FlexViewRenderer< return new FlexRenderComponentView(component, view) } - #getInjector(parentInjector?: Injector) { + #getInjector(parentInjector: Injector) { const getContext = () => this.#props() const proxy = new Proxy(this.#props(), { get: (_, key) => getContext()[key as keyof typeof _], @@ -383,7 +407,7 @@ export class FlexViewRenderer< } return Injector.create({ - parent: parentInjector ?? this.#injector(), + parent: parentInjector, providers: [ ...staticProviders, { provide: FlexRenderComponentProps, useValue: proxy }, diff --git a/packages/angular-table/src/flex-render/view.ts b/packages/angular-table/src/flex-render/view.ts index 39e93c2675..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/injectTable.ts b/packages/angular-table/src/injectTable.ts index 3486e93f7c..9cfeaca45a 100644 --- a/packages/angular-table/src/injectTable.ts +++ b/packages/angular-table/src/injectTable.ts @@ -99,14 +99,15 @@ export function injectTable< return ngZone.runOutsideAngular(() => lazyInit(() => { + const initialOptions = options() // Explicit type arguments skip generic inference from the spread object // (a type-check hot spot); the spread only adds the angular reactivity // binding to `features`. const table = constructTable({ - ...options(), + ...initialOptions, features: { coreReactivityFeature: angularReactivity(injector), - ...options().features, + ...initialOptions.features, }, }) diff --git a/packages/angular-table/src/reactivity.ts b/packages/angular-table/src/reactivity.ts index d35e0269ce..581e07f13e 100644 --- a/packages/angular-table/src/reactivity.ts +++ b/packages/angular-table/src/reactivity.ts @@ -21,9 +21,7 @@ function signalToReadonlyAtom( get: () => signal(), subscribe: (observer: Observer) => { return untracked(() => - toObservable(computed(signal), { injector: injector }).subscribe( - observer, - ), + toObservable(signal, { injector: injector }).subscribe(observer), ) }, }) @@ -47,9 +45,7 @@ function signalToWritableAtom( get: () => signal(), subscribe: (observer: Observer) => { return untracked(() => - toObservable(computed(signal), { injector: injector }).subscribe( - observer, - ), + toObservable(signal, { injector: injector }).subscribe(observer), ) }, }) diff --git a/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts b/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts index 698ec30ad7..638f90e400 100644 --- a/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts +++ b/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts @@ -12,4 +12,10 @@ test('Infer component inputs', () => { // Input is optional so we can skip passing the property flexRenderComponent(Test, { inputs: {} }) + + flexRenderComponent(Test, { key: 'stable-key' }) + flexRenderComponent(Test, { key: 1 }) + + // @ts-expect-error Keys must have stable primitive identity + flexRenderComponent(Test, { key: {} }) }) diff --git a/packages/angular-table/tests/flex-render/flex-render.bench.ts b/packages/angular-table/tests/flex-render/flex-render.bench.ts new file mode 100644 index 0000000000..76e8a02a1c --- /dev/null +++ b/packages/angular-table/tests/flex-render/flex-render.bench.ts @@ -0,0 +1,239 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, + signal, +} from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { bench, describe } from 'vitest' +import { + FlexRender, + flexRenderComponent, + injectTable, + stockFeatures, +} from '../../src' +import type { ColumnDef } from '../../src' + +const benchmarkOptions = { time: 2_000, warmupTime: 500 } + +@Component({ + template: ` + {{ tick() }} + @for (item of items; track item) { + + {{ value }} + + } + `, + imports: [FlexRender], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class PrimitiveTable { + readonly items = Array.from({ length: 500 }, (_, index) => index) + readonly value = signal('value') + readonly tick = signal(0) + readonly context = {} + readonly render = () => this.value() +} + +@Component({ + template: ``, +}) +class RenderedComponent {} + +describe('flexRender hot paths', () => { + const fixture = TestBed.createComponent(PrimitiveTable) + fixture.detectChanges() + + bench( + 'unrelated change detection for 500 primitive cells', + () => { + fixture.componentInstance.tick.update((value) => value + 1) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'create 500 component render descriptors', + () => { + for (let index = 0; index < 500; index++) { + flexRenderComponent(RenderedComponent) + } + }, + benchmarkOptions, + ) +}) + +interface BenchmarkRow { + id: string + values: Array +} + +const rowCount = 100 +const columnCount = 12 +const largeTableData: Array = Array.from( + { length: rowCount }, + (_, rowIndex) => ({ + id: `row-${rowIndex}`, + values: Array.from( + { length: columnCount }, + (_, columnIndex) => `${rowIndex}:${columnIndex}`, + ), + }), +) +const handleActivate = () => {} + +@Component({ + selector: 'benchmark-cell-a', + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class BenchmarkCellA { + readonly value = input.required() + readonly version = input.required() + readonly activate = output() +} + +@Component({ + selector: 'benchmark-cell-b', + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class BenchmarkCellB { + readonly value = input.required() + readonly version = input.required() + readonly activate = output() +} + +@Component({ + template: ` + {{ hostTick() }} + + + @for (row of table.getRowModel().rows; track row.id) { + + @for (cell of row.getVisibleCells(); track cell.id) { + + } + + } + +
+ + {{ value }} + +
+ `, + imports: [FlexRender], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class LargeMixedTable { + readonly hostTick = signal(0) + readonly valueVersion = signal(0) + readonly componentKind = signal<'a' | 'b'>('a') + readonly contentKind = signal<'primitive' | 'component'>('primitive') + + readonly columns: Array> = + Array.from({ length: columnCount }, (_, columnIndex) => ({ + id: `column-${columnIndex}`, + accessorFn: (row) => row.values[columnIndex]!, + cell: (context) => { + const value = context.getValue() + + // Four primitive columns whose values change in place. + if (columnIndex < 4) { + return `${value}:${this.valueVersion()}` + } + + // Four stable component columns whose inputs change frequently. + if (columnIndex < 8) { + const component = + columnIndex % 2 === 0 ? BenchmarkCellA : BenchmarkCellB + return flexRenderComponent(component, { + inputs: { value, version: this.valueVersion() }, + outputs: { activate: handleActivate }, + }) + } + + // Two columns that intentionally replace component A with component B. + if (columnIndex < 10) { + const component = + this.componentKind() === 'a' ? BenchmarkCellA : BenchmarkCellB + return flexRenderComponent(component, { + inputs: { value, version: 0 }, + outputs: { activate: handleActivate }, + }) + } + + // Two columns that cross the primitive/component view boundary. + return this.contentKind() === 'primitive' + ? value + : flexRenderComponent(BenchmarkCellA, { + inputs: { value, version: 0 }, + outputs: { activate: handleActivate }, + }) + }, + })) + + readonly table = injectTable(() => ({ + data: largeTableData, + columns: this.columns, + features: stockFeatures, + getRowId: (row) => row.id, + })) +} + +describe('flexRender large mixed table', () => { + const fixture = TestBed.createComponent(LargeMixedTable) + fixture.detectChanges() + + const instance = fixture.componentInstance + const renderedCellCount = fixture.nativeElement.querySelectorAll('td').length + if (renderedCellCount !== rowCount * columnCount) { + throw new Error(`Expected 1,200 cells, rendered ${renderedCellCount}`) + } + + bench( + 'unrelated host change with 1,200 mounted cells', + () => { + instance.hostTick.update((value) => value + 1) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'update 400 primitives and 400 stable component inputs', + () => { + instance.valueVersion.update((value) => value + 1) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'replace 200 component A/B cell views', + () => { + instance.componentKind.update((value) => (value === 'a' ? 'b' : 'a')) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'switch 200 cells between primitive and component views', + () => { + instance.contentKind.update((value) => + value === 'primitive' ? 'component' : 'primitive', + ) + fixture.detectChanges() + }, + benchmarkOptions, + ) +}) diff --git a/packages/angular-table/tests/flex-render/flex-render.unit.test.ts b/packages/angular-table/tests/flex-render/flex-render.unit.test.ts index 34c1c07797..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 aee244543d..20b84a7af9 100644 --- a/packages/angular-table/tests/injectTable.test.ts +++ b/packages/angular-table/tests/injectTable.test.ts @@ -17,6 +17,21 @@ import { injectTable } from '../src' import type { PaginationState } from '../src' describe('injectTable', () => { + test('evaluates options once while constructing the table', () => { + const options = vi.fn(() => ({ + data: [], + features: stockFeatures, + columns: [], + })) + const table = TestBed.runInInjectionContext(() => injectTable(options)) + + expect(options).not.toHaveBeenCalled() + + void table.options + + expect(options).toHaveBeenCalledTimes(1) + }) + test('should support required signal inputs', async () => { type Data = { id: string; title: string } From cb351819fc10ac5e9d6bb4e565e4736f6b4cb2b3 Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Sat, 15 Aug 2026 18:17:19 +0200 Subject: [PATCH 05/23] update example --- examples/angular/realtime-trading/README.md | 351 ++------ .../angular/realtime-trading/package.json | 5 +- .../angular/realtime-trading/src/app/app.ts | 74 +- .../src/app/benchmark-profiles.ts | 65 -- .../src/app/benchmark/benchmark-monitor.ts | 96 ++- .../benchmark/trading-benchmark.controller.ts | 129 +++ .../src/app/beta-trading-table.ts | 43 - .../app/core/trading-benchmark.controller.ts | 290 ------- .../src/app/current-trading-table.ts | 43 - .../src/app/feed/feed-load-profiles.ts | 18 + .../src/app/feed}/market-data.ts | 2 +- .../src/app/feed/market-feed.service.ts | 181 +++++ .../app/feed/worker}/market-feed-engine.ts | 82 +- .../app/feed/worker}/market-feed-protocol.ts | 40 +- .../src/app/feed/worker/market-feed.worker.ts | 142 ++++ .../src/app/market-feed.worker.ts | 133 --- .../src/app/shell/configurator.html | 136 ++-- .../src/app/shell/configurator.ts | 35 +- .../src/app/shell/diagnostics.ts | 22 +- .../src/app/shell/market-statusbar.ts | 8 +- .../src/app/shell/market-toolbar.ts | 59 -- .../src/app/shell/metrics-strip.ts | 44 +- .../src/app/shell/selected-instrument.ts | 11 +- .../src/app/shell/shell-header.ts | 64 +- .../src/app/shell/trading-shell.html | 35 +- .../src/app/shell/trading-shell.ts | 13 +- .../src/app/table-row-model.worker.ts | 44 - .../realtime-trading/src/app/table-v8.html | 48 -- .../realtime-trading/src/app/table-v9.html | 40 - .../src/app/table/current-trading-table.ts | 110 +++ .../{ => table/table-config}/quote-cells.ts | 106 +-- .../table-config}/trading-column-types.ts | 2 - .../app/table/table-config/trading-columns.ts | 199 +++++ .../src/app/table/table-interactions.ts | 181 +++++ .../src/app/table/table-v9.html | 132 +++ .../src/app/table/trading-row-virtualizer.ts | 101 +++ .../app/table/trading-table-initial-fit.ts | 63 ++ .../table/view/trading-grid-cell.directive.ts | 95 +++ .../view/trading-grid-selection.directive.ts | 52 ++ .../app/table/view/trading-header-cell.html | 53 ++ .../src/app/table/view/trading-header-cell.ts | 64 ++ .../table/worker/table-row-model.worker.ts | 59 ++ .../app/table/worker/worker-trading-table.ts | 137 ++++ .../src/app/trading-columns-beta.ts | 168 ---- .../src/app/trading-columns-v8.ts | 165 ---- .../src/app/trading-columns.ts | 165 ---- .../src/app/v8-trading-table.ts | 43 - .../src/app/worker-trading-table.ts | 79 -- .../angular/realtime-trading/src/index.html | 1 + .../angular/realtime-trading/src/styles.css | 765 +++++++++++++----- .../realtime-trading/tests/e2e/smoke.spec.ts | 176 ++-- examples/react/realtime-trading/README.md | 356 ++------ examples/react/realtime-trading/package.json | 3 +- examples/react/realtime-trading/src/App.tsx | 21 +- .../src/benchmark-profiles.ts | 65 -- .../src/benchmark/benchmark-monitor.ts | 124 +-- .../benchmark/trading-benchmark-controller.ts | 255 ++++++ .../src/benchmark/use-table-benchmark.ts | 29 +- .../use-trading-benchmark-controller.ts | 5 +- .../src/core/trading-benchmark-controller.ts | 532 ------------ .../src/core/use-trading-table-runtime.ts | 40 - .../src/feed/feed-load-profiles.ts | 18 + .../src/{ => feed}/market-data.ts | 2 +- .../src/feed/market-feed-controller.ts | 240 ++++++ .../src/feed/use-market-feed-controller.ts | 14 + .../src/feed/worker}/market-feed-engine.ts | 74 +- .../src/feed/worker}/market-feed-protocol.ts | 40 +- .../src/feed/worker/market-feed.worker.ts | 142 ++++ examples/react/realtime-trading/src/index.css | 711 +++++++++++----- .../src/market-feed.worker.ts | 133 --- .../src/shell/TradingShell.tsx | 765 +++++++++--------- .../src/shell/trading-shell-context.tsx | 42 +- .../{ => table/table-config}/quote-cells.tsx | 47 +- .../table-config/trading-table-config.tsx | 351 ++++++++ .../src/table/table-interactions.ts | 107 +++ .../src/table/trading-table.tsx | 505 ++++++++++++ .../src/trading-table-local.tsx | 144 ---- .../src/trading-table-shared.tsx | 317 -------- .../realtime-trading/src/trading-table-v8.tsx | 77 -- .../realtime-trading/src/trading-table.tsx | 15 - .../realtime-trading/tests/e2e/smoke.spec.ts | 87 +- .../sp500-instruments.d.ts | 27 + .../sp500-instruments.js | 718 ++++++++++++++++ examples/solid/realtime-trading/README.md | 250 ++---- examples/solid/realtime-trading/index.html | 1 + examples/solid/realtime-trading/package.json | 3 +- examples/solid/realtime-trading/src/App.tsx | 40 +- .../src/benchmark-profiles.ts | 65 -- .../src/benchmark/benchmark-monitor.ts | 109 ++- .../benchmark/trading-benchmark-controller.ts | 132 +++ .../src/core/trading-benchmark-controller.ts | 300 ------- .../src/feed/feed-load-profiles.ts | 18 + .../realtime-trading/src/feed}/market-data.ts | 2 +- .../src/feed/market-feed-controller.ts | 196 +++++ .../src/feed/worker}/market-feed-engine.ts | 82 +- .../{ => feed/worker}/market-feed-protocol.ts | 40 +- .../src/feed/worker/market-feed.worker.ts | 142 ++++ examples/solid/realtime-trading/src/index.css | 707 +++++++++++----- .../src/market-feed.worker.ts | 133 --- .../src/shell/TradingShell.tsx | 657 +++++++-------- .../src/shell/trading-shell-context.tsx | 31 +- .../{ => table/table-config}/quote-cells.tsx | 47 +- .../table/table-config/trading-columns.tsx | 240 ++++++ .../src/table/table-interactions.ts | 100 +++ .../src/table/trading-table.tsx | 335 ++++++++ .../realtime-trading/src/trading-table.tsx | 339 -------- .../realtime-trading/tests/e2e/smoke.spec.ts | 63 +- packages/angular-table/src/injectTable.ts | 19 +- pnpm-lock.yaml | 92 +-- pnpm-workspace.yaml | 6 - 110 files changed, 8775 insertions(+), 6549 deletions(-) delete mode 100644 examples/angular/realtime-trading/src/app/benchmark-profiles.ts create mode 100644 examples/angular/realtime-trading/src/app/benchmark/trading-benchmark.controller.ts delete mode 100644 examples/angular/realtime-trading/src/app/beta-trading-table.ts delete mode 100644 examples/angular/realtime-trading/src/app/core/trading-benchmark.controller.ts delete mode 100644 examples/angular/realtime-trading/src/app/current-trading-table.ts create mode 100644 examples/angular/realtime-trading/src/app/feed/feed-load-profiles.ts rename examples/{solid/realtime-trading/src => angular/realtime-trading/src/app/feed}/market-data.ts (95%) create mode 100644 examples/angular/realtime-trading/src/app/feed/market-feed.service.ts rename examples/{react/realtime-trading/src => angular/realtime-trading/src/app/feed/worker}/market-feed-engine.ts (67%) rename examples/{react/realtime-trading/src => angular/realtime-trading/src/app/feed/worker}/market-feed-protocol.ts (54%) create mode 100644 examples/angular/realtime-trading/src/app/feed/worker/market-feed.worker.ts delete mode 100644 examples/angular/realtime-trading/src/app/market-feed.worker.ts delete mode 100644 examples/angular/realtime-trading/src/app/shell/market-toolbar.ts delete mode 100644 examples/angular/realtime-trading/src/app/table-row-model.worker.ts delete mode 100644 examples/angular/realtime-trading/src/app/table-v8.html delete mode 100644 examples/angular/realtime-trading/src/app/table-v9.html create mode 100644 examples/angular/realtime-trading/src/app/table/current-trading-table.ts rename examples/angular/realtime-trading/src/app/{ => table/table-config}/quote-cells.ts (78%) rename examples/angular/realtime-trading/src/app/{ => table/table-config}/trading-column-types.ts (77%) create mode 100644 examples/angular/realtime-trading/src/app/table/table-config/trading-columns.ts create mode 100644 examples/angular/realtime-trading/src/app/table/table-interactions.ts create mode 100644 examples/angular/realtime-trading/src/app/table/table-v9.html create mode 100644 examples/angular/realtime-trading/src/app/table/trading-row-virtualizer.ts create mode 100644 examples/angular/realtime-trading/src/app/table/trading-table-initial-fit.ts create mode 100644 examples/angular/realtime-trading/src/app/table/view/trading-grid-cell.directive.ts create mode 100644 examples/angular/realtime-trading/src/app/table/view/trading-grid-selection.directive.ts create mode 100644 examples/angular/realtime-trading/src/app/table/view/trading-header-cell.html create mode 100644 examples/angular/realtime-trading/src/app/table/view/trading-header-cell.ts create mode 100644 examples/angular/realtime-trading/src/app/table/worker/table-row-model.worker.ts create mode 100644 examples/angular/realtime-trading/src/app/table/worker/worker-trading-table.ts delete mode 100644 examples/angular/realtime-trading/src/app/trading-columns-beta.ts delete mode 100644 examples/angular/realtime-trading/src/app/trading-columns-v8.ts delete mode 100644 examples/angular/realtime-trading/src/app/trading-columns.ts delete mode 100644 examples/angular/realtime-trading/src/app/v8-trading-table.ts delete mode 100644 examples/angular/realtime-trading/src/app/worker-trading-table.ts delete mode 100644 examples/react/realtime-trading/src/benchmark-profiles.ts create mode 100644 examples/react/realtime-trading/src/benchmark/trading-benchmark-controller.ts rename examples/react/realtime-trading/src/{core => benchmark}/use-trading-benchmark-controller.ts (65%) delete mode 100644 examples/react/realtime-trading/src/core/trading-benchmark-controller.ts delete mode 100644 examples/react/realtime-trading/src/core/use-trading-table-runtime.ts create mode 100644 examples/react/realtime-trading/src/feed/feed-load-profiles.ts rename examples/react/realtime-trading/src/{ => feed}/market-data.ts (95%) create mode 100644 examples/react/realtime-trading/src/feed/market-feed-controller.ts create mode 100644 examples/react/realtime-trading/src/feed/use-market-feed-controller.ts rename examples/{solid/realtime-trading/src => react/realtime-trading/src/feed/worker}/market-feed-engine.ts (70%) rename examples/{angular/realtime-trading/src/app => react/realtime-trading/src/feed/worker}/market-feed-protocol.ts (54%) create mode 100644 examples/react/realtime-trading/src/feed/worker/market-feed.worker.ts delete mode 100644 examples/react/realtime-trading/src/market-feed.worker.ts rename examples/react/realtime-trading/src/{ => table/table-config}/quote-cells.tsx (86%) create mode 100644 examples/react/realtime-trading/src/table/table-config/trading-table-config.tsx create mode 100644 examples/react/realtime-trading/src/table/table-interactions.ts create mode 100644 examples/react/realtime-trading/src/table/trading-table.tsx delete mode 100644 examples/react/realtime-trading/src/trading-table-local.tsx delete mode 100644 examples/react/realtime-trading/src/trading-table-shared.tsx delete mode 100644 examples/react/realtime-trading/src/trading-table-v8.tsx delete mode 100644 examples/react/realtime-trading/src/trading-table.tsx create mode 100644 examples/realtime-trading-shared/sp500-instruments.d.ts create mode 100644 examples/realtime-trading-shared/sp500-instruments.js delete mode 100644 examples/solid/realtime-trading/src/benchmark-profiles.ts create mode 100644 examples/solid/realtime-trading/src/benchmark/trading-benchmark-controller.ts delete mode 100644 examples/solid/realtime-trading/src/core/trading-benchmark-controller.ts create mode 100644 examples/solid/realtime-trading/src/feed/feed-load-profiles.ts rename examples/{angular/realtime-trading/src/app => solid/realtime-trading/src/feed}/market-data.ts (95%) create mode 100644 examples/solid/realtime-trading/src/feed/market-feed-controller.ts rename examples/{angular/realtime-trading/src/app => solid/realtime-trading/src/feed/worker}/market-feed-engine.ts (67%) rename examples/solid/realtime-trading/src/{ => feed/worker}/market-feed-protocol.ts (54%) create mode 100644 examples/solid/realtime-trading/src/feed/worker/market-feed.worker.ts delete mode 100644 examples/solid/realtime-trading/src/market-feed.worker.ts rename examples/solid/realtime-trading/src/{ => table/table-config}/quote-cells.tsx (86%) create mode 100644 examples/solid/realtime-trading/src/table/table-config/trading-columns.tsx create mode 100644 examples/solid/realtime-trading/src/table/table-interactions.ts create mode 100644 examples/solid/realtime-trading/src/table/trading-table.tsx delete mode 100644 examples/solid/realtime-trading/src/trading-table.tsx diff --git a/examples/angular/realtime-trading/README.md b/examples/angular/realtime-trading/README.md index 3ec6393e0f..31e5101bcf 100644 --- a/examples/angular/realtime-trading/README.md +++ b/examples/angular/realtime-trading/README.md @@ -1,285 +1,88 @@ -# Angular real-time trading flexRender lab +# Angular realtime trading benchmark -This example generates deterministic synthetic quote events in the browser. It -is designed to stress Angular Table's `flexRender` paths, not to model an -exchange or display real financial data. +This example stresses the current Angular Table adapter with a synthetic market +feed, immutable quote snapshots, dynamic cell components, row-model workloads, +and optional row virtualization. -The workload is inspired by the public -[AG Grid finance demo](https://www.ag-grid.com/example-finance/) and its -[source repository](https://github.com/ag-grid/ag-grid-demos/tree/main/finance), -but is intentionally smaller and focused on Angular `flexRender` lifecycle -behavior rather than matching that demo's features. +## Run -## Run it - -From the repository root: - -```sh -pnpm --filter tanstack-angular-table-example-realtime-trading dev -``` - -For representative measurements, serve the production configuration: - -```sh -pnpm --filter tanstack-angular-table-example-realtime-trading ng serve --configuration production --port 7777 +```bash +pnpm --dir examples/angular/realtime-trading dev ``` Open `http://localhost:7777`. -## What it exercises - -The feed control has named load profiles so comparisons do not depend on -remembering slider positions: - -- **Low** is 1k events/s. -- **Medium** is 5k events/s. -- **High** is the 10k events/s default. -- **Very high** is 25k events/s. -- **Max** requests 100k events/s and is intentionally a saturation test. -- Moving the rate slider selects **Custom**. - -Available universe sizes are 50, 100, 150, 250, 350, 500, 750, and 1,000. -The intermediate sizes make it easier to locate the point where an adapter -stops meeting its frame or throughput target. - -The row workload selector separates four different costs: - -- **Stable universe** preserves row IDs and source order. -- **Continuously sort by Last** changes input order as prices move but keeps the - same IDs, testing keyed row movement rather than destruction. -- **Rotate 20% filtered rows** excludes one of five index buckets and changes - the excluded bucket once per second, testing removal and reinsertion. -- **Replace 10% of ticker IDs** gives one of ten buckets new IDs and ticker - labels once per second. Ten percent are replacements at any instant; because - the previous bucket returns while the next enters, each transition crosses - lifecycle boundaries for roughly twenty percent of rows. - -These transformations happen before the selected adapter, so every version -receives identical arrays. They test row-model and rendering consequences; they -do not benchmark each version's public sorting or filtering API. +Use a production build for performance recordings: -The configurator can mount three implementations against the same workload: - -- **Local optimized (v9)** uses the adapter and table core from this workspace. -- **Published 9.0.0-beta.80** uses the exact npm release and its matching - `@tanstack/table-core`. -- **Published 8.21.4** uses the final v8 Angular adapter and table core 8.21.3. - -Changing the implementation select destroys the current table component and -mounts the selected one. The current immutable quote array, selected symbol, -renderer mode, and performance counters stay in the parent component, so every -adapter receives the same live state. - -The local v9 adapter also exposes a **Worker row model** checkbox. It replaces -the normal local table with a v9 table using the experimental worker plugin for -the filtered row-model stage. The row-model worker is a second worker, separate -from the market-feed worker. The checkbox is disabled for beta.80 and v8 because -those published versions do not expose this plugin. Turning it off destroys the -worker-backed table and terminates its worker. - -This table currently has no active user filter, so the worker stage returns the -full row order. That is intentional: it isolates the serialization, -postMessage, stale-while-revalidate, and row-model reconstruction overhead under -rapid immutable data replacement. It is not expected to be faster at 250โ€“1,000 -unfiltered rows; the plugin becomes more compelling when expensive filtering, -grouping, or sorting dominates the transfer cost. - -- Quote fields are plain values, matching decoded WebSocket/SSE records rather - than embedding Angular signals in the data model. -- Every worker batch publishes a new data-array reference and recreates each - changed quote object. Unchanged quotes preserve their identity, and - `getRowId` keeps table rows associated with their instruments. -- This deliberately exercises adapter option updates and table row-model - recomputation in addition to `flexRender` input updates. -- A Web Worker owns quote generation, random-walk calculations, event-rate - scheduling, history updates, and burst processing. -- One quote event updates price, bid, ask, direction, and volume; the event - counter therefore represents market messages rather than individual field - changes. -- The market-watch columns use Ticker, Last Qty, Bid / Ask Qty, Day %, Total - Qty, Traded Value, and Intraday terminology. Total Qty and Traded Value are - cumulative synthetic session fields; Last Qty is the most recent trade size. -- Bid, ask, percentage change, quantities, and traded value are primitive - renderers. -- Last price is a stable Angular component whose inputs and output callback are - updated frequently. -- Tick direction can use one stable component or switch between separate up and - down component types. -- Spread components receive bid and ask updates and recompute absolute and - basis-point spreads. -- Depth components receive bid/ask sizes on every quote and redraw a two-sided - liquidity imbalance bar. -- Quote-age components share a 100 ms clock. This intentionally invalidates the - whole Age column at once and can be disabled independently. -- Sparkline components receive new array references at a configurable cadence. -- Component create/destroy counters and the optional Chrome heap estimate help - expose unintended churn or retained component state. - -## Shared code and adapter boundaries - -Most of the example is deliberately shared: - -- `market-feed-engine.ts` is the framework-free quote algorithm that runs in - the worker. -- `market-feed.worker.ts` owns scheduling, batching, coalescing, and - backpressure. -- `market-feed-protocol.ts` defines typed commands and events shared across the - worker boundary. -- `market-data.ts` hydrates initial worker snapshots and immutably recreates - changed application rows on the main thread. -- `table-row-model.worker.ts` hosts the optional v9 shadow table used by the - experimental row-model worker plugin. -- `worker-trading-table.ts` wires the local v9 table to that worker-backed - filtered row model and terminates it when the component is destroyed. -- `quote-cells.ts` owns all dynamic Angular cell components and lifecycle - instrumentation. -- `trading-column-types.ts` contains only the renderer-mode/state contract and - the diagnostics column count. -- `trading-columns.ts` is the local-v9 column factory shared by the current and - worker-backed tables. It calls the local adapter's `flexRenderComponent` - directly only for genuine Angular component cells. -- `trading-columns-beta.ts` and `trading-columns-v8.ts` intentionally duplicate - the complete adapter-specific column configuration, including IDs, labels, - widths, and formatters. Each calls its own package's typed - `flexRenderComponent`; no token spreads, generic renderer callback, or - `unknown` cast crosses the version boundary. Primitive cells continue to - return primitive values. -- `core/trading-benchmark.controller.ts` owns application signals, worker - transport, derived table inputs, and user commands. -- `benchmark/benchmark-monitor.ts` owns browser observers, render - acknowledgements, and published metrics. -- `shell/` contains independent header, toolbar, metrics, status-bar, - diagnostics, selected-instrument, and configurator components. Each injects - the same controller directly. -- `shell/trading-shell.html` is only the layout and `` projection - point. -- `app.ts` owns the projected local/beta/v8 adapter switch. - -Table construction and component-render descriptors are version-specific. The -local and beta v9 components share `table-v9.html` and use `injectTable`, -`stockFeatures`, and the `flexRenderCell` / `flexRenderHeader` shorthand -directives. The v8 component uses `createAngularTable`, `getCoreRowModel`, and -the older direct `flexRender` microsyntax in `table-v8.html`. +```bash +pnpm --dir examples/angular/realtime-trading build +``` ## Architecture -`App` deliberately has no knowledge of workers, timers, render callbacks, or -adapter commands. It selects the active adapter and projects it through -`TradingShell`. The injected `TradingBenchmarkController` is the single -stateful boundary: it subscribes to the feed worker, converts protocol events -into immutable row snapshots, derives the selected workload, and exposes -signals plus commands to every shell component. - -Benchmark instrumentation is a separate collaborator. `BenchmarkMonitor` -contains the mutable sampling runtime and publishes immutable metric snapshots -back through the controller. This keeps measurement policy out of both the -table adapters and the presentational shell. - -All deliberate mutable runtime is grouped behind `const` object or class -identities. TypeScript source files do not use `let`; counters and handles -change as properties of those stable runtime owners instead of being scattered -mutable bindings. Angular template `@for (...; let index = ...)` declarations, -where present, are template syntax rather than JavaScript mutable bindings. - -The beta and v8 dependencies use exact npm tarball URLs. This is intentional: -the repository globally redirects `@tanstack/angular-table` to the local -workspace package, and a normal npm alias would therefore not provide an -independent published baseline. Version-specific overrides also keep each -adapter paired with the table-core version it was released against. - -## Worker transport and backpressure - -The worker is intentionally shaped like an external market-data transport. The -Angular application sends configuration commands and listens for `ready` and -`batch` messages, much as an application would listen to a WebSocket or SSE -client. - -The worker does not post one browser message for every quote event. It -coalesces all pending changes by instrument and allows only one batch to be in -flight. Angular acknowledges that batch after `afterEveryRender`; only then can -the worker send the next one. While the main thread is busy, the worker keeps -the newest snapshot for each instrument in a bounded map rather than building -an unbounded message queue. - -For that reason, **batch events** and **row updates** are separate diagnostics. -A batch may represent thousands of source events but contain at most one final -update per instrument. The event counter still measures the requested source -load, while row updates describe the amount of data copied and applied by the -UI. - -This protects the main thread from quote-generation work and queue growth, but -it does not make rendering free. Recreating changed rows, publishing the new -data array, Angular change detection, table row-model work, `flexRender`, -layout, and paint must still happen on the main thread. -In a production application, the WebSocket connection and parsing could also -live inside the worker and use this same message protocol. If the WebSocket or -`EventSource` is created in the window instead, its JavaScript event handlers -still run on the main thread. - -## Performance metrics - -The dashboard keeps browser scheduling and Angular work separate: - -- **RAF rate** counts `requestAnimationFrame` callbacks and divides them by the - real wall-clock sampling duration. It is not clamped and therefore includes - long main-thread stalls. It describes browser frame opportunities, not table - renders, and will normally follow the display refresh rate. -- **Table renders** counts worker batches that reach `afterEveryRender` and are - acknowledged back to the worker. This is the UI's actual feed-render - throughput. -- **Average / P95 render** measures worker-message reception through Angular's - completed render callback. It excludes worker calculation, browser paint, - and time coalescing behind an in-flight batch. -- **Long frames** uses a feature-detected `PerformanceObserver` with the - `long-animation-frame` entry type. It counts complete browser animation - frames longer than 50 ms and records the worst duration since reset. -- **Renders over 16.7 ms** remains an application-level diagnostic for the most - recent sample. It assumes a 60 Hz frame budget and is intentionally distinct - from the browser's Long Animation Frames API. - -Long Animation Frames are currently supported by Chromium-based browsers. The -dashboard shows `N/A` when the browser does not expose that performance entry -type. For exact frame, layout, paint, and compositor attribution, record the -same workload in the browser's Performance tools. - -## A repeatable comparison - -1. Use the same production browser build and machine for both commits. -2. Start at 250 instruments, 10k events/s, stable Tick renderer, quote ages, - and sparklines enabled. -3. Select one implementation, reset the session, and let it warm up for 20โ€“30 - seconds. -4. Record actual throughput, RAF rate, table renders/s, P95 render time, long - frames, live components, and heap over a fixed measurement window. -5. Repeat step 3 for the other implementations without changing the controls. -6. Increase the event target until actual throughput cannot keep up or P95 - exceeds the 16.7 ms frame budget. -7. Repeat with component swapping, quote ages, and sparklines toggled - separately. The 25k burst is useful for profiling worker aggregation and one - large coalesced UI update. - -Component create/destroy totals are cumulative across adapter switches. A -switch should destroy the old table's dynamic cell components and create the -new table's components, so both totals jump by design. Likewise, the JS heap -can rise temporarily while the old tree waits for garbage collection. Compare -post-GC heap plateaus after several identical switch cycles; a rising raw heap -line by itself does not prove a leak. - -Immutable batches also allocate a new array plus one object per changed row. -Those short-lived objects are expected garbage. Memory analysis should compare -post-GC plateaus rather than expecting a flat allocation graph. - -The displayed Chrome heap estimate is diagnostic only. Treat the browser's -Memory profiler as the source of truth when separating the main realm, worker -realm, detached DOM, and garbage awaiting collection. - -Because this lab bundles three adapter and table-core generations, its download -size is intentionally larger than a normal application and should not be used -for bundle-size comparison. - -The UI resembles a market-watch blotter, but the feed is deterministic -synthetic data rather than an exchange simulator. A production-confidence test -should replay a timestamped, sanitized capture through the worker protocol so -burstiness and symbol skew are preserved. Use browser Performance and Memory -recordings as the source of truth for scripting, layout, paint, detached nodes, -and post-GC heap; the in-app counters are comparison aids. +- `TradingBenchmarkController` owns feed state, benchmark controls, derived + quote snapshots, and diagnostics. +- `market-feed.worker.ts` produces synthetic quote samples outside the main + thread. Multiple samples for the same instrument are coalesced into one row + update per published message. +- `CurrentTradingTable` renders the current Angular Table implementation. +- `WorkerTradingTable` uses the same columns and template while moving the + filtered row-model stage to the experimental table worker plugin. +- `trading-row-virtualizer.ts` integrates TanStack Virtual and reports the + visible and mounted row ranges. +- Components under `shell/` own the surrounding terminal UI and inject the + controller directly. + +The application root only selects between the normal and worker-backed current +table. There are no historical adapter implementations in this benchmark. + +The Angular 22 application is zoneless by default. Components use signal +inputs, outputs, queries and derived state; resource cleanup is registered with +`DestroyRef`, and no `ngOnInit`, `ngOnDestroy`, `@HostBinding`, or +`@HostListener` hooks are used. + +## Row rendering + +The row count and the row-rendering control select the rendering path: + +- Below 1,500 instruments, **TanStack Virtual** can be enabled or disabled to + compare it with **Full DOM**. +- At 1,500 instruments or more, **TanStack Virtual** is enabled and locked. It + mounts a keyed overscan window and positions rows with a transform. +- Both paths apply `content-visibility: auto` and a fixed intrinsic row height; + the Full DOM path can therefore skip offscreen browser rendering without + avoiding framework work or DOM creation. + +Angular CDK scrolling is intentionally not part of this example. + +## Feed rate + +The configured sample rate controls synthetic quote generation inside the +worker. It is not a browser event or `postMessage` rate. The publish interval +separately controls the target message cadence. The worker coalesces repeated +instruments in each message and posts independently of render completion, like +an external stream. Random seeds stay private to the feed engine. + +## Workloads + +- The market-data universe preserves row order and IDs. +- **Swap Tick component A โ†” B** exercises component destruction and creation. +- Sparkline and quote-age toggles control high-frequency component input + invalidation. +- Intraday history is sampled independently per instrument in the worker. Its + minimum sampling interval is configurable from 100 ms to 2,000 ms. + +## Dependency resolution + +The example pins `@tanstack/angular-table` to the current repository version. +The root workspace override resolves that dependency to `packages/angular-table` +while preserving a release-like manifest. + +## Verification + +```bash +pnpm --dir examples/angular/realtime-trading test:types +pnpm --dir examples/angular/realtime-trading lint +pnpm --dir examples/angular/realtime-trading build +pnpm --dir examples/angular/realtime-trading test:e2e +``` diff --git a/examples/angular/realtime-trading/package.json b/examples/angular/realtime-trading/package.json index 7156570054..1f270dbc84 100644 --- a/examples/angular/realtime-trading/package.json +++ b/examples/angular/realtime-trading/package.json @@ -17,9 +17,8 @@ "@angular/compiler": "^22.1.0", "@angular/core": "^22.1.0", "@angular/platform-browser": "^22.1.0", - "@tanstack/angular-table": "^9.0.0", - "@tanstack/angular-table-beta": "https://registry.npmjs.org/@tanstack/angular-table/-/angular-table-9.0.0-beta.80.tgz", - "@tanstack/angular-table-v8": "https://registry.npmjs.org/@tanstack/angular-table/-/angular-table-8.21.4.tgz", + "@tanstack/angular-table": "9.1.3", + "@tanstack/angular-virtual": "^6.0.2", "rxjs": "~7.8.2", "tslib": "^2.8.1" }, diff --git a/examples/angular/realtime-trading/src/app/app.ts b/examples/angular/realtime-trading/src/app/app.ts index 553d3dad06..e348e9374c 100644 --- a/examples/angular/realtime-trading/src/app/app.ts +++ b/examples/angular/realtime-trading/src/app/app.ts @@ -1,64 +1,30 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core' -import { BetaTradingTable } from './beta-trading-table' -import { TradingBenchmarkController } from './core/trading-benchmark.controller' -import { CurrentTradingTable } from './current-trading-table' +import { TradingBenchmarkController } from './benchmark/trading-benchmark.controller' +import { CurrentTradingTable } from './table/current-trading-table' import { TradingShell } from './shell/trading-shell' -import { V8TradingTable } from './v8-trading-table' -import { WorkerTradingTable } from './worker-trading-table' +import { WorkerTradingTable } from './table/worker/worker-trading-table' @Component({ selector: 'app-root', - imports: [ - BetaTradingTable, - CurrentTradingTable, - TradingShell, - V8TradingTable, - WorkerTradingTable, - ], + imports: [CurrentTradingTable, TradingShell, WorkerTradingTable], template: ` - @switch (controller.tableAdapter()) { - @case ('local') { - @if (controller.tableWorkerEnabled()) { - - } @else { - - } - } - @case ('beta') { - - } - @case ('v8') { - - } + @if (controller.tableWorkerEnabled()) { + + } @else { + } `, diff --git a/examples/angular/realtime-trading/src/app/benchmark-profiles.ts b/examples/angular/realtime-trading/src/app/benchmark-profiles.ts deleted file mode 100644 index 848fc28880..0000000000 --- a/examples/angular/realtime-trading/src/app/benchmark-profiles.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { MarketQuote } from './market-data' - -export type FeedLoadProfile = - 'low' | 'medium' | 'high' | 'very-high' | 'max' | 'custom' - -export type RowWorkloadMode = - 'stable' | 'price-sort' | 'rotating-filter' | 'identity-churn' - -export const feedLoadRates: Record< - Exclude, - number -> = { - low: 1_000, - medium: 5_000, - high: 10_000, - 'very-high': 25_000, - max: 100_000, -} - -export function deriveBenchmarkQuotes( - quotes: Array, - mode: RowWorkloadMode, - epoch: number, -): Array { - if (mode === 'stable') { - return quotes - } - - if (mode === 'price-sort') { - return [...quotes].sort( - (left, right) => - right.price - left.price || left.symbol.localeCompare(right.symbol), - ) - } - - if (mode === 'rotating-filter') { - const excludedBucket = epoch % 5 - return quotes.filter((_, index) => index % 5 !== excludedBucket) - } - - const replacementBucket = epoch % 10 - return quotes.map((quote, index) => - index % 10 === replacementBucket - ? { - ...quote, - id: `${quote.id}-replacement-${epoch}`, - symbol: `${quote.symbol}R${epoch % 100}`, - company: `${quote.company} replacement`, - } - : quote, - ) -} - -export function rowWorkloadLabel(mode: RowWorkloadMode): string { - switch (mode) { - case 'price-sort': - return 'PRICE REORDER' - case 'rotating-filter': - return 'FILTER ROTATION' - case 'identity-churn': - return 'TICKER REPLACEMENT' - default: - return 'STABLE UNIVERSE' - } -} diff --git a/examples/angular/realtime-trading/src/app/benchmark/benchmark-monitor.ts b/examples/angular/realtime-trading/src/app/benchmark/benchmark-monitor.ts index d92631820f..830c4100b5 100644 --- a/examples/angular/realtime-trading/src/app/benchmark/benchmark-monitor.ts +++ b/examples/angular/realtime-trading/src/app/benchmark/benchmark-monitor.ts @@ -1,9 +1,12 @@ -import { quoteCellLifecycle } from '../quote-cells' -import type { MarketFeedCommand } from '../market-feed-protocol' +import { quoteCellLifecycle } from '../table/table-config/quote-cells' export interface FeedMetrics { - actualEventsPerSecond: number - totalEvents: number + actualTicksPerSecond: number + rowUpdatesPerSecond: number + workerMessagesPerSecond: number + stateApplicationsPerSecond: number + supersededUpdatesPerSecond: number + totalTicks: number rafCallbacksPerSecond: number tableRendersPerSecond: number lastBatchSize: number @@ -21,8 +24,12 @@ export interface FeedMetrics { } export const initialMetrics: FeedMetrics = { - actualEventsPerSecond: 0, - totalEvents: 0, + actualTicksPerSecond: 0, + rowUpdatesPerSecond: 0, + workerMessagesPerSecond: 0, + stateApplicationsPerSecond: 0, + supersededUpdatesPerSecond: 0, + totalTicks: 0, rafCallbacksPerSecond: 0, tableRendersPerSecond: 0, lastBatchSize: 0, @@ -39,19 +46,17 @@ export const initialMetrics: FeedMetrics = { lastUpdateCount: 0, } -interface PendingAck { - generation: number - sequence: number -} - export class BenchmarkMonitor { readonly #runtime = { sampleStartedAt: performance.now(), pendingRenderStartedAt: null as number | null, - pendingAck: null as PendingAck | null, renderSamples: [] as Array, - totalEvents: 0, - eventsInSample: 0, + totalTicks: 0, + ticksInSample: 0, + rowUpdatesInSample: 0, + workerMessagesInSample: 0, + stateApplicationsInSample: 0, + supersededUpdatesInSample: 0, lastBatchSize: 0, lastUpdateCount: 0, workerMessages: 0, @@ -65,32 +70,35 @@ export class BenchmarkMonitor { this.#runtime.pendingRenderStartedAt ??= performance.now() } - setPendingAck(ack: PendingAck | null): void { - this.#runtime.pendingAck = ack - } - - recordCompletedRender(postCommand: (command: MarketFeedCommand) => void) { + recordCompletedRender(): void { const runtime = this.#runtime if (runtime.pendingRenderStartedAt !== null) { runtime.renderSamples.push( performance.now() - runtime.pendingRenderStartedAt, ) runtime.pendingRenderStartedAt = null - } - if (runtime.pendingAck) { runtime.tableRendersInSample++ - postCommand({ type: 'ack', ...runtime.pendingAck }) - runtime.pendingAck = null } } - recordBatch(eventCount: number, updateCount: number): void { + recordWorkerMessage(): void { + this.#runtime.workerMessages++ + this.#runtime.workerMessagesInSample++ + } + + recordBatch( + tickCount: number, + updateCount: number, + supersededUpdateCount: number, + ): void { const runtime = this.#runtime - runtime.lastBatchSize = eventCount + runtime.lastBatchSize = tickCount runtime.lastUpdateCount = updateCount - runtime.eventsInSample += eventCount - runtime.totalEvents += eventCount - runtime.workerMessages++ + runtime.ticksInSample += tickCount + runtime.totalTicks += tickCount + runtime.rowUpdatesInSample += updateCount + runtime.stateApplicationsInSample++ + runtime.supersededUpdatesInSample += supersededUpdateCount } recordAnimationFrame(): void { @@ -126,11 +134,19 @@ export class BenchmarkMonitor { Math.ceil(sortedRenderSamples.length * 0.95) - 1, ) const metrics: FeedMetrics = { - actualEventsPerSecond: + actualTicksPerSecond: sampleDuration === 0 ? 0 - : (runtime.eventsInSample / sampleDuration) * 1_000, - totalEvents: runtime.totalEvents, + : (runtime.ticksInSample / sampleDuration) * 1_000, + rowUpdatesPerSecond: + (runtime.rowUpdatesInSample / sampleDuration) * 1_000, + workerMessagesPerSecond: + (runtime.workerMessagesInSample / sampleDuration) * 1_000, + stateApplicationsPerSecond: + (runtime.stateApplicationsInSample / sampleDuration) * 1_000, + supersededUpdatesPerSecond: + (runtime.supersededUpdatesInSample / sampleDuration) * 1_000, + totalTicks: runtime.totalTicks, rafCallbacksPerSecond: (runtime.rafCallbacksInSample / sampleDuration) * 1_000, tableRendersPerSecond: @@ -139,8 +155,7 @@ export class BenchmarkMonitor { averageRenderMs, p95RenderMs: sortedRenderSamples[p95Index] ?? 0, maxRenderMs: sortedRenderSamples.at(-1) ?? 0, - slowRenders: runtime.renderSamples.filter((value) => value > 16.7) - .length, + slowRenders: runtime.renderSamples.filter((value) => value > 16.7).length, longAnimationFrames: runtime.longAnimationFrameCount, worstLongAnimationFrameMs: runtime.worstLongAnimationFrameMs, heapMb: readHeapSizeMb(), @@ -151,7 +166,11 @@ export class BenchmarkMonitor { } runtime.sampleStartedAt = now - runtime.eventsInSample = 0 + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 runtime.renderSamples = [] runtime.rafCallbacksInSample = 0 runtime.tableRendersInSample = 0 @@ -162,10 +181,13 @@ export class BenchmarkMonitor { const runtime = this.#runtime runtime.sampleStartedAt = performance.now() runtime.pendingRenderStartedAt = null - runtime.pendingAck = null runtime.renderSamples = [] - runtime.totalEvents = 0 - runtime.eventsInSample = 0 + runtime.totalTicks = 0 + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 runtime.lastBatchSize = 0 runtime.lastUpdateCount = 0 runtime.workerMessages = 0 diff --git a/examples/angular/realtime-trading/src/app/benchmark/trading-benchmark.controller.ts b/examples/angular/realtime-trading/src/app/benchmark/trading-benchmark.controller.ts new file mode 100644 index 0000000000..8f066ef474 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/benchmark/trading-benchmark.controller.ts @@ -0,0 +1,129 @@ +import { + DestroyRef, + Injectable, + computed, + inject, + isDevMode, + signal, +} from '@angular/core' +import { TRADING_COLUMN_COUNT } from '../table/table-config/trading-column-types' +import { MarketFeedService } from '../feed/market-feed.service' +import { BenchmarkMonitor, initialMetrics } from './benchmark-monitor' +import type { VirtualScrollMode } from '../table/trading-row-virtualizer' +import type { FeedMetrics } from './benchmark-monitor' +import type { RendererMode } from '../table/table-config/trading-column-types' + +const FORCED_VIRTUALIZATION_ROW_COUNT = 1_500 + +@Injectable({ providedIn: 'root' }) +export class TradingBenchmarkController { + readonly #destroyRef = inject(DestroyRef) + readonly #monitor = new BenchmarkMonitor() + readonly #feed = inject(MarketFeedService) + readonly #longAnimationFrameObserver: PerformanceObserver | null + + readonly devMode = isDevMode() + readonly longAnimationFramesSupported = + PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') + readonly tableWorkerEnabled = signal(false) + readonly renderedRowCount = signal(0) + readonly rendererMode = signal('stable') + readonly requestedVirtualScrollMode = signal('none') + readonly selectedSymbol = signal(null) + readonly metrics = signal(initialMetrics) + readonly displayQuotes = this.#feed.quotes + readonly selectedQuote = computed(() => { + const symbol = this.selectedSymbol() + return symbol + ? (this.displayQuotes().find((quote) => quote.symbol === symbol) ?? null) + : null + }) + readonly virtualScrollForced = computed( + () => this.#feed.instrumentCount() >= FORCED_VIRTUALIZATION_ROW_COUNT, + ) + readonly virtualScrollMode = computed(() => + this.virtualScrollForced() ? 'tanstack' : this.requestedVirtualScrollMode(), + ) + readonly mountedCells = computed( + () => this.renderedRowCount() * TRADING_COLUMN_COUNT, + ) + readonly liveComponents = computed(() => { + const metrics = this.metrics() + return metrics.componentsCreated - metrics.componentsDestroyed + }) + + #animationFrameId: number | null = null + + constructor() { + const stopObservingFeed = this.#feed.observe({ + messageReceived: () => this.#monitor.recordWorkerMessage(), + mutationStarted: () => this.#monitor.markRenderPending(), + batchApplied: ({ tickCount, updateCount, supersededUpdateCount }) => + this.#monitor.recordBatch( + tickCount, + updateCount, + supersededUpdateCount, + ), + renderCommitted: () => this.#monitor.recordCompletedRender(), + }) + this.#longAnimationFrameObserver = this.longAnimationFramesSupported + ? new PerformanceObserver(this.#recordLongAnimationFrames) + : null + this.#longAnimationFrameObserver?.observe({ + type: 'long-animation-frame', + buffered: true, + }) + this.#animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + this.#destroyRef.onDestroy(() => { + stopObservingFeed() + if (this.#animationFrameId !== null) { + cancelAnimationFrame(this.#animationFrameId) + } + this.#longAnimationFrameObserver?.disconnect() + }) + } + + setRendererMode(shouldSwap: boolean): void { + this.rendererMode.set(shouldSwap ? 'swap' : 'stable') + } + + setTableWorkerEnabled(enabled: boolean): void { + this.tableWorkerEnabled.set(enabled) + } + + setVirtualScrollEnabled(enabled: boolean): void { + if (this.virtualScrollForced()) return + this.requestedVirtualScrollMode.set(enabled ? 'tanstack' : 'none') + } + + setRenderedRowCount(count: number): void { + this.renderedRowCount.set(count) + } + + resetViewState(): void { + this.selectedSymbol.set(null) + } + + resetMarket(): void { + this.#monitor.reset() + this.resetViewState() + this.metrics.set(initialMetrics) + this.#feed.reset() + } + + readonly #benchmarkFrame = (now: number): void => { + this.#monitor.recordAnimationFrame() + if (this.#monitor.shouldPublish(now)) { + this.metrics.set(this.#monitor.publish(now)) + } + this.#animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + } + + readonly #recordLongAnimationFrames = ( + entries: PerformanceObserverEntryList, + ): void => { + for (const entry of entries.getEntries()) { + this.#monitor.recordLongAnimationFrame(entry.duration) + } + } +} diff --git a/examples/angular/realtime-trading/src/app/beta-trading-table.ts b/examples/angular/realtime-trading/src/app/beta-trading-table.ts deleted file mode 100644 index 2576824653..0000000000 --- a/examples/angular/realtime-trading/src/app/beta-trading-table.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - input, - output, -} from '@angular/core' -import { - FlexRender, - injectTable, - stockFeatures, -} from '@tanstack/angular-table-beta' -import { createBetaTradingColumns } from './trading-columns-beta' -import type { MarketQuote } from './market-data' -import type { RendererMode } from './trading-column-types' - -@Component({ - selector: 'app-beta-trading-table', - imports: [FlexRender], - templateUrl: './table-v9.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class BetaTradingTable { - readonly quotes = input.required>() - readonly rendererMode = input.required() - readonly updateQuoteAges = input.required() - readonly quoteClock = input.required() - readonly selectedSymbol = input(null) - readonly symbolSelected = output() - - readonly columns = createBetaTradingColumns({ - rendererMode: () => this.rendererMode(), - updateQuoteAges: () => this.updateQuoteAges(), - quoteClock: () => this.quoteClock(), - selectSymbol: (symbol) => this.symbolSelected.emit(symbol), - }) - - readonly table = injectTable(() => ({ - data: this.quotes(), - columns: this.columns, - features: stockFeatures, - getRowId: (row) => row.id, - })) -} diff --git a/examples/angular/realtime-trading/src/app/core/trading-benchmark.controller.ts b/examples/angular/realtime-trading/src/app/core/trading-benchmark.controller.ts deleted file mode 100644 index 462d024dbf..0000000000 --- a/examples/angular/realtime-trading/src/app/core/trading-benchmark.controller.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { - DestroyRef, - Injectable, - NgZone, - afterEveryRender, - computed, - inject, - isDevMode, - signal, -} from '@angular/core' -import { - deriveBenchmarkQuotes, - feedLoadRates, - rowWorkloadLabel, -} from '../benchmark-profiles' -import { - BenchmarkMonitor, - initialMetrics, -} from '../benchmark/benchmark-monitor' -import { applyMarketUpdates, hydrateMarketQuotes } from '../market-data' -import { TRADING_COLUMN_COUNT } from '../trading-column-types' -import type { FeedMetrics } from '../benchmark/benchmark-monitor' -import type { FeedLoadProfile, RowWorkloadMode } from '../benchmark-profiles' -import type { - MarketFeedCommand, - MarketFeedEvent, -} from '../market-feed-protocol' -import type { MarketQuote } from '../market-data' -import type { RendererMode } from '../trading-column-types' - -export type TableAdapter = 'local' | 'beta' | 'v8' - -@Injectable({ providedIn: 'root' }) -export class TradingBenchmarkController { - readonly #zone = inject(NgZone) - readonly #destroyRef = inject(DestroyRef) - readonly #worker: Worker - readonly #longAnimationFrameObserver: PerformanceObserver | null - readonly #monitor = new BenchmarkMonitor() - - readonly devMode = isDevMode() - readonly longAnimationFramesSupported = - PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') - readonly workerReady = signal(false) - readonly running = signal(true) - readonly instrumentCount = signal(250) - readonly feedLoadProfile = signal('high') - readonly targetEventsPerSecond = signal(10_000) - readonly rowWorkloadMode = signal('stable') - readonly rowWorkloadEpoch = signal(0) - readonly tableAdapter = signal('local') - readonly tableWorkerEnabled = signal(false) - readonly rendererMode = signal('stable') - readonly updateSparklines = signal(true) - readonly updateQuoteAges = signal(true) - readonly quoteClock = signal(Date.now()) - readonly quotes = signal>([]) - readonly selectedSymbol = signal(null) - readonly metrics = signal(initialMetrics) - readonly displayQuotes = computed(() => - deriveBenchmarkQuotes( - this.quotes(), - this.rowWorkloadMode(), - this.rowWorkloadEpoch(), - ), - ) - readonly selectedQuote = computed(() => { - const symbol = this.selectedSymbol() - return symbol - ? (this.displayQuotes().find((quote) => quote.symbol === symbol) ?? null) - : null - }) - - readonly mountedCells = computed( - () => this.displayQuotes().length * TRADING_COLUMN_COUNT, - ) - readonly rowWorkloadLabel = computed(() => - rowWorkloadLabel(this.rowWorkloadMode()), - ) - readonly liveComponents = computed(() => { - const metrics = this.metrics() - return metrics.componentsCreated - metrics.componentsDestroyed - }) - - #animationFrameId: number | null = null - #feedGeneration = 0 - #lastAgeClockAt = performance.now() - #lastRowWorkloadAt = performance.now() - - constructor() { - this.#worker = new Worker( - new URL('../market-feed.worker', import.meta.url), - { type: 'module' }, - ) - this.#longAnimationFrameObserver = this.longAnimationFramesSupported - ? new PerformanceObserver(this.#recordLongAnimationFrames) - : null - afterEveryRender(() => - this.#monitor.recordCompletedRender((command) => - this.#postToWorker(command), - ), - ) - this.#zone.runOutsideAngular(() => { - this.#worker.addEventListener('message', this.#handleWorkerMessage) - this.#worker.addEventListener('error', this.#handleWorkerError) - this.#longAnimationFrameObserver?.observe({ - type: 'long-animation-frame', - buffered: true, - }) - this.#animationFrameId = requestAnimationFrame(this.#feedFrame) - }) - this.#postToWorker({ - type: 'initialize', - rowCount: this.instrumentCount(), - seed: 42 + this.instrumentCount(), - running: this.running(), - targetEventsPerSecond: this.targetEventsPerSecond(), - updateSparklines: this.updateSparklines(), - }) - this.#destroyRef.onDestroy(() => { - if (this.#animationFrameId !== null) { - cancelAnimationFrame(this.#animationFrameId) - } - this.#longAnimationFrameObserver?.disconnect() - this.#worker.terminate() - }) - } - - toggleFeed(): void { - const running = !this.running() - this.running.set(running) - this.#postToWorker({ type: 'configure', running }) - } - - setRowCount(count: number): void { - this.instrumentCount.set(count) - this.#resetWorkerMarket(count) - } - - setTargetRate(rate: number): void { - this.feedLoadProfile.set('custom') - this.targetEventsPerSecond.set(rate) - this.#postToWorker({ - type: 'configure', - targetEventsPerSecond: rate, - }) - } - - setFeedLoadProfile(profile: FeedLoadProfile): void { - this.feedLoadProfile.set(profile) - if (profile === 'custom') { - return - } - - const rate = feedLoadRates[profile] - this.targetEventsPerSecond.set(rate) - this.#postToWorker({ - type: 'configure', - targetEventsPerSecond: rate, - }) - } - - setRowWorkloadMode(mode: RowWorkloadMode): void { - this.rowWorkloadMode.set(mode) - this.rowWorkloadEpoch.set(0) - this.#lastRowWorkloadAt = performance.now() - this.selectedSymbol.set(null) - } - - setTableAdapter(adapter: TableAdapter): void { - this.tableAdapter.set(adapter) - } - - setRendererMode(shouldSwap: boolean): void { - this.rendererMode.set(shouldSwap ? 'swap' : 'stable') - } - - setTableWorkerEnabled(enabled: boolean): void { - this.tableWorkerEnabled.set(enabled) - } - - setSparklineUpdates(updateSparklines: boolean): void { - this.updateSparklines.set(updateSparklines) - this.#postToWorker({ type: 'configure', updateSparklines }) - } - - setQuoteAgeUpdates(enabled: boolean): void { - this.updateQuoteAges.set(enabled) - } - - runBurst(): void { - this.#postToWorker({ type: 'burst', eventCount: 25_000 }) - } - - resetMarket(): void { - const count = this.instrumentCount() - this.selectedSymbol.set(null) - this.#monitor.reset() - this.#lastRowWorkloadAt = performance.now() - this.rowWorkloadEpoch.set(0) - this.quoteClock.set(Date.now()) - this.metrics.set(initialMetrics) - this.#resetWorkerMarket(count) - } - - readonly #feedFrame = (now: number): void => { - this.#monitor.recordAnimationFrame() - - if (this.updateQuoteAges() && now - this.#lastAgeClockAt >= 100) { - this.#monitor.markRenderPending() - this.#lastAgeClockAt = now - this.quoteClock.set(Date.now()) - } - - if ( - this.running() && - (this.rowWorkloadMode() === 'rotating-filter' || - this.rowWorkloadMode() === 'identity-churn') && - now - this.#lastRowWorkloadAt >= 1_000 - ) { - this.#monitor.markRenderPending() - this.#lastRowWorkloadAt = now - this.rowWorkloadEpoch.update((epoch) => epoch + 1) - } - - if (this.#monitor.shouldPublish(now)) { - this.metrics.set(this.#monitor.publish(now)) - } - - this.#animationFrameId = requestAnimationFrame(this.#feedFrame) - } - - readonly #recordLongAnimationFrames = ( - entries: PerformanceObserverEntryList, - ): void => { - for (const entry of entries.getEntries()) { - this.#monitor.recordLongAnimationFrame(entry.duration) - } - } - - readonly #handleWorkerMessage = ({ - data, - }: MessageEvent): void => { - if (data.type === 'ready') { - this.#feedGeneration = data.generation - this.#monitor.setPendingAck(null) - this.#monitor.markRenderPending() - this.quotes.set(hydrateMarketQuotes(data.quotes)) - this.workerReady.set(true) - return - } - - if (data.generation !== this.#feedGeneration) { - this.#postToWorker({ - type: 'ack', - generation: data.generation, - sequence: data.sequence, - }) - return - } - - this.#monitor.markRenderPending() - this.quotes.update((quotes) => applyMarketUpdates(quotes, data.updates)) - this.#monitor.recordBatch(data.eventCount, data.updates.length) - this.#monitor.setPendingAck({ - generation: data.generation, - sequence: data.sequence, - }) - } - - readonly #handleWorkerError = (error: ErrorEvent): void => { - this.workerReady.set(false) - this.running.set(false) - console.error('Market feed worker failed', error) - } - - #resetWorkerMarket(rowCount: number): void { - this.workerReady.set(false) - this.#monitor.setPendingAck(null) - this.#postToWorker({ - type: 'reset', - rowCount, - seed: 42 + rowCount, - }) - } - - #postToWorker(command: MarketFeedCommand): void { - this.#worker.postMessage(command) - } -} diff --git a/examples/angular/realtime-trading/src/app/current-trading-table.ts b/examples/angular/realtime-trading/src/app/current-trading-table.ts deleted file mode 100644 index 3d6225a519..0000000000 --- a/examples/angular/realtime-trading/src/app/current-trading-table.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - input, - output, -} from '@angular/core' -import { - FlexRender, - injectTable, - stockFeatures, -} from '@tanstack/angular-table' -import { createTradingColumns } from './trading-columns' -import type { MarketQuote } from './market-data' -import type { RendererMode } from './trading-column-types' - -@Component({ - selector: 'app-current-trading-table', - imports: [FlexRender], - templateUrl: './table-v9.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class CurrentTradingTable { - readonly quotes = input.required>() - readonly rendererMode = input.required() - readonly updateQuoteAges = input.required() - readonly quoteClock = input.required() - readonly selectedSymbol = input(null) - readonly symbolSelected = output() - - readonly columns = createTradingColumns({ - rendererMode: () => this.rendererMode(), - updateQuoteAges: () => this.updateQuoteAges(), - quoteClock: () => this.quoteClock(), - selectSymbol: (symbol) => this.symbolSelected.emit(symbol), - }) - - readonly table = injectTable(() => ({ - data: this.quotes(), - columns: this.columns, - features: stockFeatures, - getRowId: (row) => row.id, - })) -} diff --git a/examples/angular/realtime-trading/src/app/feed/feed-load-profiles.ts b/examples/angular/realtime-trading/src/app/feed/feed-load-profiles.ts new file mode 100644 index 0000000000..417177ff0f --- /dev/null +++ b/examples/angular/realtime-trading/src/app/feed/feed-load-profiles.ts @@ -0,0 +1,18 @@ +export type FeedLoadProfile = + | 'low' + | 'medium' + | 'high' + | 'very-high' + | 'max' + | 'custom' + +export const feedLoadRates: Record< + Exclude, + number +> = { + low: 1_000, + medium: 5_000, + high: 10_000, + 'very-high': 25_000, + max: 100_000, +} diff --git a/examples/solid/realtime-trading/src/market-data.ts b/examples/angular/realtime-trading/src/app/feed/market-data.ts similarity index 95% rename from examples/solid/realtime-trading/src/market-data.ts rename to examples/angular/realtime-trading/src/app/feed/market-data.ts index 02eefa28c3..6231865d3f 100644 --- a/examples/solid/realtime-trading/src/market-data.ts +++ b/examples/angular/realtime-trading/src/app/feed/market-data.ts @@ -1,7 +1,7 @@ import type { MarketQuoteSnapshot, MarketQuoteUpdate, -} from './market-feed-protocol' +} from './worker/market-feed-protocol' export interface MarketQuote extends Omit { history: ReadonlyArray diff --git a/examples/angular/realtime-trading/src/app/feed/market-feed.service.ts b/examples/angular/realtime-trading/src/app/feed/market-feed.service.ts new file mode 100644 index 0000000000..56bc97f5af --- /dev/null +++ b/examples/angular/realtime-trading/src/app/feed/market-feed.service.ts @@ -0,0 +1,181 @@ +import { + DestroyRef, + Injectable, + afterEveryRender, + inject, + signal, +} from '@angular/core' +import { feedLoadRates } from './feed-load-profiles' +import { applyMarketUpdates, hydrateMarketQuotes } from './market-data' +import type { FeedLoadProfile } from './feed-load-profiles' +import type { + MarketFeedCommand, + MarketFeedEvent, +} from './worker/market-feed-protocol' +import type { MarketQuote } from './market-data' + +export interface MarketFeedBatch { + tickCount: number + updateCount: number + supersededUpdateCount: number +} + +export interface MarketFeedObserver { + messageReceived?: () => void + mutationStarted?: () => void + batchApplied?: (batch: MarketFeedBatch) => void + renderCommitted?: () => void +} + +@Injectable({ providedIn: 'root' }) +export class MarketFeedService { + readonly #destroyRef = inject(DestroyRef) + readonly #worker = new Worker( + new URL('./worker/market-feed.worker', import.meta.url), + { type: 'module' }, + ) + readonly #observers = new Set() + + readonly workerReady = signal(false) + readonly running = signal(true) + readonly instrumentCount = signal(250) + readonly feedLoadProfile = signal('high') + readonly targetTicksPerSecond = signal(10_000) + readonly publishIntervalMs = signal(20) + readonly updateSparklines = signal(true) + readonly sparklineSampleIntervalMs = signal(250) + readonly quotes = signal>([]) + + #feedSessionId = 0 + #renderPending = false + + constructor() { + afterEveryRender(() => this.completeRender()) + this.#worker.addEventListener('message', this.#handleWorkerMessage) + this.#worker.addEventListener('error', this.#handleWorkerError) + this.#post({ + type: 'start', + rowCount: this.instrumentCount(), + running: this.running(), + ticksPerSecond: this.targetTicksPerSecond(), + publishIntervalMs: this.publishIntervalMs(), + updateSparklines: this.updateSparklines(), + sparklineSampleIntervalMs: this.sparklineSampleIntervalMs(), + }) + this.#destroyRef.onDestroy(() => { + this.#worker.removeEventListener('message', this.#handleWorkerMessage) + this.#worker.removeEventListener('error', this.#handleWorkerError) + this.#worker.terminate() + this.#observers.clear() + }) + } + + observe(observer: MarketFeedObserver): () => void { + this.#observers.add(observer) + return () => this.#observers.delete(observer) + } + + completeRender(): void { + if (!this.#renderPending) return + + this.#renderPending = false + for (const observer of this.#observers) { + observer.renderCommitted?.() + } + } + + toggle(): void { + const running = !this.running() + this.running.set(running) + this.#post({ type: 'set-running', running }) + } + + setInstrumentCount(count: number): void { + this.instrumentCount.set(count) + this.reset() + } + + setTargetRate(rate: number): void { + this.feedLoadProfile.set('custom') + this.targetTicksPerSecond.set(rate) + this.#post({ type: 'set-rate', ticksPerSecond: rate }) + } + + setLoadProfile(profile: FeedLoadProfile): void { + this.feedLoadProfile.set(profile) + if (profile === 'custom') return + + const rate = feedLoadRates[profile] + this.targetTicksPerSecond.set(rate) + this.#post({ type: 'set-rate', ticksPerSecond: rate }) + } + + setPublishInterval(publishIntervalMs: number): void { + this.publishIntervalMs.set(publishIntervalMs) + this.#post({ type: 'set-publish-interval', intervalMs: publishIntervalMs }) + } + + setSparklineUpdates(enabled: boolean): void { + this.updateSparklines.set(enabled) + this.#post({ type: 'set-sparklines', enabled }) + } + + setSparklineSampleInterval(intervalMs: number): void { + this.sparklineSampleIntervalMs.set(intervalMs) + this.#post({ type: 'set-sparkline-interval', intervalMs }) + } + + runBurst(): void { + this.#post({ type: 'burst', tickCount: 25_000 }) + } + + reset(): void { + this.workerReady.set(false) + this.#post({ type: 'reset', rowCount: this.instrumentCount() }) + } + + readonly #handleWorkerMessage = ({ + data, + }: MessageEvent): void => { + if (data.type === 'snapshot') { + this.#feedSessionId = data.sessionId + this.#startMutation() + this.quotes.set(hydrateMarketQuotes(data.quotes)) + this.workerReady.set(true) + return + } + + if (data.sessionId !== this.#feedSessionId) return + + for (const observer of this.#observers) { + observer.messageReceived?.() + } + this.#startMutation() + this.quotes.update((quotes) => applyMarketUpdates(quotes, data.updates)) + const batch = { + tickCount: data.tickCount, + updateCount: data.updates.length, + supersededUpdateCount: data.coalescedUpdateCount, + } + for (const observer of this.#observers) { + observer.batchApplied?.(batch) + } + } + + readonly #handleWorkerError = (error: ErrorEvent): void => { + this.workerReady.set(false) + this.running.set(false) + console.error('Market feed worker failed', error) + } + + #startMutation(): void { + this.#renderPending = true + for (const observer of this.#observers) { + observer.mutationStarted?.() + } + } + + #post(command: MarketFeedCommand): void { + this.#worker.postMessage(command) + } +} diff --git a/examples/react/realtime-trading/src/market-feed-engine.ts b/examples/angular/realtime-trading/src/app/feed/worker/market-feed-engine.ts similarity index 67% rename from examples/react/realtime-trading/src/market-feed-engine.ts rename to examples/angular/realtime-trading/src/app/feed/worker/market-feed-engine.ts index e77d76656c..c562427bba 100644 --- a/examples/react/realtime-trading/src/market-feed-engine.ts +++ b/examples/angular/realtime-trading/src/app/feed/worker/market-feed-engine.ts @@ -1,39 +1,30 @@ +import { globalInstruments } from '../../../../../../realtime-trading-shared/sp500-instruments' import type { MarketQuoteSnapshot, MarketQuoteUpdate, } from './market-feed-protocol' -const baseInstruments = [ - ['ALP', 'Alpine Systems', 'XNAS'], - ['ARC', 'Arcadia Cloud', 'XNYS'], - ['BLU', 'Blue River Energy', 'BATS'], - ['CRN', 'Crown Robotics', 'XNAS'], - ['DYN', 'Dynasty Networks', 'XNYS'], - ['ECO', 'Ecoframe Materials', 'IEX'], - ['FLX', 'Flux Semiconductors', 'XNAS'], - ['GEO', 'Geode Analytics', 'BATS'], - ['HLX', 'Helix Biotech', 'XNYS'], - ['ION', 'Ion Mobility', 'IEX'], - ['JDE', 'Jade Financial', 'XNYS'], - ['KNT', 'Kinetic Aerospace', 'XNAS'], -] as const +const INITIAL_MARKET_SEED = 0x4d41524b +const LIVE_FEED_SEED = 0x5449434b export class MarketFeedEngine { #quotes: Array = [] + #lastHistorySampledAt = new Uint32Array(0) #random = createRandom(2_026) #rowCursor = 0 - #historyTick = 0 - #eventIndex = 0 + #tickIndex = 0 - reset(count: number, seed: number): Array { - const random = createRandom(seed) + reset(count: number): Array { + const random = createRandom(INITIAL_MARKET_SEED ^ count) + const createdAt = Date.now() this.#quotes = Array.from({ length: count }, (_, index) => { - const [baseSymbol, company, venue] = - baseInstruments[index % baseInstruments.length] - const series = Math.floor(index / baseInstruments.length) + const [baseSymbol, company, market] = + globalInstruments[index % globalInstruments.length] + const series = Math.floor(index / globalInstruments.length) const symbol = series === 0 ? baseSymbol : `${baseSymbol}${series}` - const open = roundPrice(20 + random() * 480) + const previousClose = roundPrice(20 + random() * 480) + const open = roundPrice(previousClose * (1 + (random() - 0.5) * 0.016)) const spread = Math.max(0.01, open * (0.0002 + random() * 0.0004)) const history = Array.from({ length: 24 }, (_, historyIndex) => roundPrice( @@ -48,8 +39,11 @@ export class MarketFeedEngine { id: `instrument-${index}`, symbol, company, - venue, + venue: market, + previousClose, open, + high: open, + low: open, price: open, bid: roundPrice(open - spread / 2), ask: roundPrice(open + spread / 2), @@ -57,16 +51,17 @@ export class MarketFeedEngine { askSize: Math.floor(100 + random() * 25_000), lastSize, lastMove: 0, - lastUpdatedAt: Date.now(), + lastUpdatedAt: createdAt, volume, turnover: roundMoney(open * volume), history, } }) - this.#random = createRandom(2_026 + seed) + this.#random = createRandom(LIVE_FEED_SEED ^ count) this.#rowCursor = 0 - this.#historyTick = 0 + this.#lastHistorySampledAt = new Uint32Array(count) + this.#lastHistorySampledAt.fill(createdAt >>> 0) return this.#quotes.map((quote) => ({ ...quote, @@ -74,22 +69,31 @@ export class MarketFeedEngine { })) } - applyEvents( - eventCount: number, + applyTicks( + tickCount: number, updateSparklines: boolean, + sparklineSampleIntervalMs: number, ): Array { - if (this.#quotes.length === 0 || eventCount <= 0) return [] + if (this.#quotes.length === 0 || tickCount <= 0) return [] const updatedAt = Date.now() + const sampledAt = updatedAt >>> 0 + const sampleIntervalMs = Math.max(16, sparklineSampleIntervalMs) const updatedQuotes = new Map() const stride = 97 - this.#eventIndex = 0 - while (this.#eventIndex < eventCount) { + this.#tickIndex = 0 + while (this.#tickIndex < tickCount) { this.#rowCursor = (this.#rowCursor + stride) % this.#quotes.length const quote = this.#quotes[this.#rowCursor] + const lastSampledAt = this.#lastHistorySampledAt[this.#rowCursor] const shouldUpdateHistory = - updateSparklines && this.#historyTick++ % 4 === 0 + updateSparklines && + (sampledAt - lastSampledAt) >>> 0 >= sampleIntervalMs + + if (shouldUpdateHistory) { + this.#lastHistorySampledAt[this.#rowCursor] = sampledAt + } this.#applyTick(quote, shouldUpdateHistory, updatedAt) @@ -104,13 +108,15 @@ export class MarketFeedEngine { lastSize: quote.lastSize, lastMove: quote.lastMove, lastUpdatedAt: quote.lastUpdatedAt, + high: quote.high, + low: quote.low, volume: quote.volume, turnover: quote.turnover, ...(shouldUpdateHistory || previousUpdate?.history ? { history: [...quote.history] } : {}), }) - this.#eventIndex++ + this.#tickIndex++ } return [...updatedQuotes.values()] @@ -132,6 +138,8 @@ export class MarketFeedEngine { quote.lastMove = nextPrice - previousPrice quote.price = nextPrice + quote.high = Math.max(quote.high, nextPrice) + quote.low = Math.min(quote.low, nextPrice) quote.bid = roundPrice(nextPrice - spread / 2) quote.ask = roundPrice(nextPrice + spread / 2) quote.bidSize = Math.floor(100 + this.#random() * 25_000) @@ -152,13 +160,9 @@ function createRandom(seed: number): () => number { return () => { runtime.state += 0x6d2b79f5 const stateValue = runtime.state - const firstMix = Math.imul( - stateValue ^ (stateValue >>> 15), - stateValue | 1, - ) + const firstMix = Math.imul(stateValue ^ (stateValue >>> 15), stateValue | 1) const secondMix = - firstMix + - Math.imul(firstMix ^ (firstMix >>> 7), firstMix | 61) + firstMix + Math.imul(firstMix ^ (firstMix >>> 7), firstMix | 61) const value = firstMix ^ secondMix return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296 } diff --git a/examples/react/realtime-trading/src/market-feed-protocol.ts b/examples/angular/realtime-trading/src/app/feed/worker/market-feed-protocol.ts similarity index 54% rename from examples/react/realtime-trading/src/market-feed-protocol.ts rename to examples/angular/realtime-trading/src/app/feed/worker/market-feed-protocol.ts index 0a8b9925f5..21997ec89c 100644 --- a/examples/react/realtime-trading/src/market-feed-protocol.ts +++ b/examples/angular/realtime-trading/src/app/feed/worker/market-feed-protocol.ts @@ -3,7 +3,10 @@ export interface MarketQuoteSnapshot { symbol: string company: string venue: string + previousClose: number open: number + high: number + low: number price: number bid: number ask: number @@ -27,6 +30,8 @@ export interface MarketQuoteUpdate { lastSize: number lastMove: number lastUpdatedAt: number + high: number + low: number volume: number turnover: number history?: Array @@ -34,33 +39,32 @@ export interface MarketQuoteUpdate { export type MarketFeedCommand = | { - type: 'initialize' + type: 'start' rowCount: number - seed: number running: boolean - targetEventsPerSecond: number + ticksPerSecond: number + publishIntervalMs: number updateSparklines: boolean + sparklineSampleIntervalMs: number } - | { - type: 'configure' - running?: boolean - targetEventsPerSecond?: number - updateSparklines?: boolean - } - | { type: 'reset'; rowCount: number; seed: number } - | { type: 'burst'; eventCount: number } - | { type: 'ack'; generation: number; sequence: number } + | { type: 'set-running'; running: boolean } + | { type: 'set-rate'; ticksPerSecond: number } + | { type: 'set-publish-interval'; intervalMs: number } + | { type: 'set-sparklines'; enabled: boolean } + | { type: 'set-sparkline-interval'; intervalMs: number } + | { type: 'reset'; rowCount: number } + | { type: 'burst'; tickCount: number } export type MarketFeedEvent = | { - type: 'ready' - generation: number + type: 'snapshot' + sessionId: number quotes: Array } | { - type: 'batch' - generation: number - sequence: number - eventCount: number + type: 'updates' + sessionId: number + tickCount: number + coalescedUpdateCount: number updates: Array } diff --git a/examples/angular/realtime-trading/src/app/feed/worker/market-feed.worker.ts b/examples/angular/realtime-trading/src/app/feed/worker/market-feed.worker.ts new file mode 100644 index 0000000000..7b22656f2e --- /dev/null +++ b/examples/angular/realtime-trading/src/app/feed/worker/market-feed.worker.ts @@ -0,0 +1,142 @@ +import { MarketFeedEngine } from './market-feed-engine' +import type { + MarketFeedCommand, + MarketFeedEvent, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const engine = new MarketFeedEngine() +const pendingUpdates = new Map() +const runtime = { + sessionId: 0, + pendingTickCount: 0, + pendingCoalescedUpdateCount: 0, + initialized: false, + running: true, + ticksPerSecond: 10_000, + publishIntervalMs: 20, + publishTimerId: null as ReturnType | null, + updateSparklines: true, + sparklineSampleIntervalMs: 250, + tickBudget: 0, + lastTickAt: performance.now(), +} + +addEventListener('message', ({ data }: MessageEvent) => { + switch (data.type) { + case 'start': + runtime.running = data.running + runtime.ticksPerSecond = data.ticksPerSecond + runtime.publishIntervalMs = data.publishIntervalMs + runtime.updateSparklines = data.updateSparklines + runtime.sparklineSampleIntervalMs = data.sparklineSampleIntervalMs + restartPublishTimer() + reset(data.rowCount) + break + case 'set-running': + runtime.running = data.running + break + case 'set-rate': + runtime.ticksPerSecond = data.ticksPerSecond + break + case 'set-publish-interval': + runtime.publishIntervalMs = Math.max(4, data.intervalMs) + restartPublishTimer() + break + case 'set-sparklines': + runtime.updateSparklines = data.enabled + break + case 'set-sparkline-interval': + runtime.sparklineSampleIntervalMs = Math.max(16, data.intervalMs) + break + case 'reset': + reset(data.rowCount) + break + case 'burst': + produceTicks(data.tickCount) + flush() + break + } +}) + +setInterval(() => { + const now = performance.now() + const elapsed = Math.min(100, Math.max(0, now - runtime.lastTickAt)) + runtime.lastTickAt = now + + if (runtime.initialized && runtime.running) { + runtime.tickBudget += (runtime.ticksPerSecond * elapsed) / 1_000 + const tickCount = Math.floor(runtime.tickBudget) + runtime.tickBudget -= tickCount + produceTicks(tickCount) + } +}, 16) + +restartPublishTimer() + +function reset(rowCount: number): void { + runtime.initialized = true + runtime.sessionId++ + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + runtime.tickBudget = 0 + runtime.lastTickAt = performance.now() + + post({ + type: 'snapshot', + sessionId: runtime.sessionId, + quotes: engine.reset(rowCount), + }) +} + +function produceTicks(tickCount: number): void { + if (!runtime.initialized || tickCount <= 0) return + + runtime.pendingTickCount += tickCount + for (const update of engine.applyTicks( + tickCount, + runtime.updateSparklines, + runtime.sparklineSampleIntervalMs, + )) { + const previousUpdate = pendingUpdates.get(update.index) + if (previousUpdate) runtime.pendingCoalescedUpdateCount++ + pendingUpdates.set(update.index, { + ...update, + ...(update.history || !previousUpdate?.history + ? {} + : { history: previousUpdate.history }), + }) + } +} + +function flush(): void { + if (runtime.pendingTickCount === 0) return + + const message: MarketFeedEvent = { + type: 'updates', + sessionId: runtime.sessionId, + tickCount: runtime.pendingTickCount, + coalescedUpdateCount: runtime.pendingCoalescedUpdateCount, + updates: [...pendingUpdates.values()], + } + + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + post(message) +} + +function restartPublishTimer(): void { + if (runtime.publishTimerId !== null) { + clearInterval(runtime.publishTimerId) + } + runtime.publishTimerId = setInterval( + flush, + Math.max(4, runtime.publishIntervalMs), + ) +} + +function post(event: MarketFeedEvent): void { + postMessage(event) +} diff --git a/examples/angular/realtime-trading/src/app/market-feed.worker.ts b/examples/angular/realtime-trading/src/app/market-feed.worker.ts deleted file mode 100644 index 7ce6313798..0000000000 --- a/examples/angular/realtime-trading/src/app/market-feed.worker.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { MarketFeedEngine } from './market-feed-engine' -import type { - MarketFeedCommand, - MarketFeedEvent, - MarketQuoteUpdate, -} from './market-feed-protocol' - -const engine = new MarketFeedEngine() -const pendingUpdates = new Map() - -const runtime = { - generation: 0, - sequence: 0, - inFlightSequence: null as number | null, - pendingEventCount: 0, - initialized: false, - running: true, - targetEventsPerSecond: 10_000, - updateSparklines: true, - eventBudget: 0, - lastTickAt: performance.now(), -} - -addEventListener('message', ({ data }: MessageEvent) => { - switch (data.type) { - case 'initialize': - runtime.running = data.running - runtime.targetEventsPerSecond = data.targetEventsPerSecond - runtime.updateSparklines = data.updateSparklines - reset(data.rowCount, data.seed) - break - case 'configure': - runtime.running = data.running ?? runtime.running - runtime.targetEventsPerSecond = - data.targetEventsPerSecond ?? runtime.targetEventsPerSecond - runtime.updateSparklines = - data.updateSparklines ?? runtime.updateSparklines - break - case 'reset': - reset(data.rowCount, data.seed) - break - case 'burst': - produceEvents(data.eventCount) - flush() - break - case 'ack': - if ( - data.generation === runtime.generation && - data.sequence === runtime.inFlightSequence - ) { - runtime.inFlightSequence = null - flush() - } - break - } -}) - -setInterval(() => { - const now = performance.now() - const elapsed = Math.min(100, Math.max(0, now - runtime.lastTickAt)) - runtime.lastTickAt = now - - if (runtime.initialized && runtime.running) { - runtime.eventBudget += - (runtime.targetEventsPerSecond * elapsed) / 1_000 - const eventCount = Math.floor(runtime.eventBudget) - runtime.eventBudget -= eventCount - produceEvents(eventCount) - } - - flush() -}, 16) - -function reset(rowCount: number, seed: number): void { - runtime.initialized = true - runtime.generation++ - runtime.sequence = 0 - runtime.inFlightSequence = null - runtime.pendingEventCount = 0 - pendingUpdates.clear() - runtime.eventBudget = 0 - runtime.lastTickAt = performance.now() - - post({ - type: 'ready', - generation: runtime.generation, - quotes: engine.reset(rowCount, seed), - }) -} - -function produceEvents(eventCount: number): void { - if (!runtime.initialized || eventCount <= 0) return - - runtime.pendingEventCount += eventCount - for (const update of engine.applyEvents( - eventCount, - runtime.updateSparklines, - )) { - const previousUpdate = pendingUpdates.get(update.index) - pendingUpdates.set(update.index, { - ...update, - ...(update.history || !previousUpdate?.history - ? {} - : { history: previousUpdate.history }), - }) - } -} - -function flush(): void { - if ( - runtime.inFlightSequence !== null || - runtime.pendingEventCount === 0 - ) - return - - const nextSequence = ++runtime.sequence - const message: MarketFeedEvent = { - type: 'batch', - generation: runtime.generation, - sequence: nextSequence, - eventCount: runtime.pendingEventCount, - updates: [...pendingUpdates.values()], - } - - runtime.pendingEventCount = 0 - pendingUpdates.clear() - runtime.inFlightSequence = nextSequence - post(message) -} - -function post(event: MarketFeedEvent): void { - postMessage(event) -} diff --git a/examples/angular/realtime-trading/src/app/shell/configurator.html b/examples/angular/realtime-trading/src/app/shell/configurator.html index c5c1b47f4a..465bca411c 100644 --- a/examples/angular/realtime-trading/src/app/shell/configurator.html +++ b/examples/angular/realtime-trading/src/app/shell/configurator.html @@ -1,42 +1,20 @@ -