From bb629bf450b8ed5d5105ef7b1c08a17a82fec532 Mon Sep 17 00:00:00 2001 From: Frederic Bahr Date: Sat, 16 May 2026 19:33:32 +0200 Subject: [PATCH 1/2] feat: use lit-store internally instead of reyling on vanilla store dependency --- packages/lit-table/package.json | 2 +- packages/lit-table/src/TableController.ts | 83 +++++++++++++++++++++-- packages/lit-table/src/reactivity.ts | 2 +- pnpm-lock.yaml | 16 ++++- 4 files changed, 91 insertions(+), 12 deletions(-) diff --git a/packages/lit-table/package.json b/packages/lit-table/package.json index 3ec49ecc2f..136a29722e 100644 --- a/packages/lit-table/package.json +++ b/packages/lit-table/package.json @@ -57,7 +57,7 @@ "build": "tsdown" }, "dependencies": { - "@tanstack/store": "^0.11.0", + "@tanstack/lit-store": "^0.13.2", "@tanstack/table-core": "workspace:*" }, "devDependencies": { diff --git a/packages/lit-table/src/TableController.ts b/packages/lit-table/src/TableController.ts index c3a67f2940..5e933d237c 100644 --- a/packages/lit-table/src/TableController.ts +++ b/packages/lit-table/src/TableController.ts @@ -1,7 +1,13 @@ import { constructTable } from '@tanstack/table-core' +import { TanStackStoreSelector } from '@tanstack/lit-store' import { litReactivity } from './reactivity' import { FlexRender } from './flexRender' -import type { Atom, ReadonlyAtom, ReadonlyStore, Store } from '@tanstack/store' +import type { + Atom, + ReadonlyAtom, + ReadonlyStore, + Store, +} from '@tanstack/lit-store' import type { NoInfer, RowData, @@ -146,6 +152,13 @@ export class TableController< private _storeSubscription?: { unsubscribe: () => void } private _optionsSubscription?: { unsubscribe: () => void } private _notifier = 0 + private _selectorCache = new WeakMap< + SubscribeSource, + Map< + ((state: unknown) => unknown) | undefined, + TanStackStoreSelector + > + >() constructor(host: ReactiveControllerHost) { ;(this.host = host).addController(this) @@ -205,23 +218,28 @@ export class TableController< const tableInstance = this._table // Attach Subscribe function - const Subscribe = function Subscribe(props: { + const Subscribe = ((props: { source?: SubscribeSource selector?: (state: unknown) => unknown children: | ((state: Readonly) => TemplateResult | string) | TemplateResult | string - }): TemplateResult | string { + }): TemplateResult | string => { const source = props.source ?? tableInstance.store - const value = source.get() - const selectedState = - props.selector !== undefined ? props.selector(value) : value + + const storeSelector: TanStackStoreSelector = + this._getOrCreateSelector(source, props.selector) + + // TODO: update to newest version of Tanstack Store: https://github.com/TanStack/store/pull/329 + const selectedState = storeSelector.value + if (typeof props.children === 'function') { return props.children(selectedState as Readonly) } + return props.children - } as LitTable['Subscribe'] + }) as LitTable['Subscribe'] return { ...this._table, @@ -257,5 +275,56 @@ export class TableController< this._storeSubscription = undefined this._optionsSubscription?.unsubscribe() this._optionsSubscription = undefined + this._selectorCache = new WeakMap() + } + + /** + * Get or create a TanStackStoreSelector for the given source and selector. + * + * Caches selectors by source (WeakMap) and selector function to avoid + * creating new controllers on every render cycle. + * + * @param source The atom or store to subscribe to + * @param selector Optional selector function to select a slice of the source state + * @returns A cached TanStackStoreSelector instance that subscribes to the source and applies the selector + */ + private _getOrCreateSelector = ( + source?: SubscribeSource, + selector?: (state: unknown) => unknown, + ): TanStackStoreSelector => { + if (!source) { + return new TanStackStoreSelector(this.host, () => source, selector) + } + + if (!this._selectorCache.has(source)) { + this._selectorCache.set(source, new Map()) + } + const selectorMap = this._selectorCache.get(source) + + // Get or create the selector for this source + selector combination + if (selectorMap?.has(selector)) { + return ( + selectorMap.get(selector) ?? + this.createSelectorForSource(source, selector) + ) + } + + const storeSelector = this.createSelectorForSource(source, selector) + selectorMap?.set(selector, storeSelector) + + return storeSelector + } + + /** + * Create a new TanStackStoreSelector for the given source and selector without caching. + * @param source The atom or store to subscribe to + * @param selector Optional selector function to select a slice of the source state + * @returns A new TanStackStoreSelector instance that subscribes to the source and applies the selector + */ + private createSelectorForSource = ( + source: SubscribeSource, + selector?: (state: unknown) => unknown, + ): TanStackStoreSelector => { + return new TanStackStoreSelector(this.host, () => source, selector) } } diff --git a/packages/lit-table/src/reactivity.ts b/packages/lit-table/src/reactivity.ts index 01e180e246..fe4403acfc 100644 --- a/packages/lit-table/src/reactivity.ts +++ b/packages/lit-table/src/reactivity.ts @@ -1,4 +1,4 @@ -import { batch, createAtom } from '@tanstack/store' +import { batch, createAtom } from '@tanstack/lit-store' import type { TableAtomOptions, TableReactivityBindings, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7124801b69..cd0b29ae8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8797,9 +8797,9 @@ importers: packages/lit-table: dependencies: - '@tanstack/store': - specifier: ^0.11.0 - version: 0.11.0 + '@tanstack/lit-store': + specifier: ^0.13.2 + version: 0.13.2(lit@3.3.3) '@tanstack/table-core': specifier: workspace:* version: link:../table-core @@ -13741,6 +13741,11 @@ packages: resolution: {integrity: sha512-Psl+oDiidLvtctswkTQ1P6sQIihwrMLcdfQVfkLpO42oKwxWEr1lodWUHiOG5jFXsGwDDvpUv/WAdlmJF+yGpw==} hasBin: true + '@tanstack/lit-store@0.13.2': + resolution: {integrity: sha512-uAa5gQbmPOESokHM5t+AhQ3Ye8S2/bN+PB6CHKjHXixNN6aMDSQEtHoguPoMb3a3q/NeJ2NdiseyoUg4vsh/ng==} + peerDependencies: + lit: ^3.0.0 + '@tanstack/lit-virtual@3.13.29': resolution: {integrity: sha512-fnFZ2cFcOEskbLIzNwPgN1sZ9R4xxG3wWsD8WnHRbKlUZUs9qs1lyYiakXaIHHpKedYNbhmYaP9iYYGoXwYSYQ==} peerDependencies: @@ -23719,6 +23724,11 @@ snapshots: semver: 7.8.2 yaml: 2.8.3 + '@tanstack/lit-store@0.13.2(lit@3.3.3)': + dependencies: + '@tanstack/store': 0.11.0 + lit: 3.3.3 + '@tanstack/lit-virtual@3.13.29(lit@3.3.3)': dependencies: '@tanstack/virtual-core': 3.17.0 From 3e5b3ee6692c7dc5441bd37aaea33b5ecf810cb4 Mon Sep 17 00:00:00 2001 From: Frederic Bahr Date: Mon, 8 Jun 2026 21:22:47 +0200 Subject: [PATCH 2/2] refactor: implement subscribe as a lit directive An async lit directive allows for fine grained reactivity as it only updates when an external source (here the atom/store) is updated. This aligns closely with the fine-grained reactivity model we have in react. --- packages/lit-table/src/TableController.ts | 163 +++------------- packages/lit-table/src/index.ts | 1 + packages/lit-table/src/subscribe-directive.ts | 174 ++++++++++++++++++ 3 files changed, 202 insertions(+), 136 deletions(-) create mode 100644 packages/lit-table/src/subscribe-directive.ts diff --git a/packages/lit-table/src/TableController.ts b/packages/lit-table/src/TableController.ts index 5e933d237c..1b634d63c8 100644 --- a/packages/lit-table/src/TableController.ts +++ b/packages/lit-table/src/TableController.ts @@ -1,32 +1,16 @@ import { constructTable } from '@tanstack/table-core' -import { TanStackStoreSelector } from '@tanstack/lit-store' import { litReactivity } from './reactivity' import { FlexRender } from './flexRender' + +import { subscribe } from './subscribe-directive' import type { - Atom, - ReadonlyAtom, - ReadonlyStore, - Store, -} from '@tanstack/lit-store' -import type { - NoInfer, RowData, Table, TableFeatures, TableOptions, TableState, } from '@tanstack/table-core' -import type { - ReactiveController, - ReactiveControllerHost, - TemplateResult, -} from 'lit' - -export type SubscribeSource = - | Atom - | ReadonlyAtom - | Store - | ReadonlyStore +import type { ReactiveController, ReactiveControllerHost } from 'lit' /** * The extended table type returned by the Lit adapter. @@ -46,45 +30,34 @@ export type LitTable< */ readonly store: Table['store'] /** - * Subscribe to a selected slice of table state, or to a single source (atom or store). - * - * **Lit note:** `TableController` still wires host updates via the full `table.store` - * subscription — source mode matches the React API and reads `source.get()` at render - * time. True source-only invalidation can be added later via `source.subscribe`. + * Subscribes to the table's underlying state store within a Lit template. + * Re-renders only the targeted template slice when the observed state changes. * * @example * ```ts - * table.Subscribe({ - * selector: (state) => ({ rowSelection: state.rowSelection }), - * children: (state) => html`
${JSON.stringify(state)}
`, - * }) + * // 1. Subscribe to a specific state slice (re-renders ONLY when rowSelection changes) + * html` + *
+ * ${table.subscribe( + * table.store, + * (state) => state.rowSelection, + * (rowSelection) => html`Selected: ${JSON.stringify(rowSelection)}` + * )} + *
+ * ` + * + * // 2. Subscribe to the full state (re-renders on any state mutation) + * html` + *
+ * ${table.subscribe( + * table.store, + * (state) => html`Total rows: ${state.rowModel.rows.length}` + * )} + *
+ * ` * ``` */ - Subscribe: { - (props: { - source: SubscribeSource - selector?: undefined - children: - | ((state: Readonly) => TemplateResult | string) - | TemplateResult - | string - }): TemplateResult | string - (props: { - source: SubscribeSource - selector: (state: TSourceValue) => TSubscribeSelected - children: - | ((state: Readonly) => TemplateResult | string) - | TemplateResult - | string - }): TemplateResult | string - (props: { - selector: (state: NoInfer>) => TSubscribeSelected - children: - | ((state: Readonly) => TemplateResult | string) - | TemplateResult - | string - }): TemplateResult | string - } + subscribe: typeof subscribe /** * The selected state of the table. This state may not match the structure of * the full table state because it is selected by the selector function that @@ -152,13 +125,6 @@ export class TableController< private _storeSubscription?: { unsubscribe: () => void } private _optionsSubscription?: { unsubscribe: () => void } private _notifier = 0 - private _selectorCache = new WeakMap< - SubscribeSource, - Map< - ((state: unknown) => unknown) | undefined, - TanStackStoreSelector - > - >() constructor(host: ReactiveControllerHost) { ;(this.host = host).addController(this) @@ -217,33 +183,9 @@ export class TableController< // Capture for closure const tableInstance = this._table - // Attach Subscribe function - const Subscribe = ((props: { - source?: SubscribeSource - selector?: (state: unknown) => unknown - children: - | ((state: Readonly) => TemplateResult | string) - | TemplateResult - | string - }): TemplateResult | string => { - const source = props.source ?? tableInstance.store - - const storeSelector: TanStackStoreSelector = - this._getOrCreateSelector(source, props.selector) - - // TODO: update to newest version of Tanstack Store: https://github.com/TanStack/store/pull/329 - const selectedState = storeSelector.value - - if (typeof props.children === 'function') { - return props.children(selectedState as Readonly) - } - - return props.children - }) as LitTable['Subscribe'] - return { ...this._table, - Subscribe, + subscribe, FlexRender, get state() { return (selector?.(tableInstance.store.state) ?? @@ -275,56 +217,5 @@ export class TableController< this._storeSubscription = undefined this._optionsSubscription?.unsubscribe() this._optionsSubscription = undefined - this._selectorCache = new WeakMap() - } - - /** - * Get or create a TanStackStoreSelector for the given source and selector. - * - * Caches selectors by source (WeakMap) and selector function to avoid - * creating new controllers on every render cycle. - * - * @param source The atom or store to subscribe to - * @param selector Optional selector function to select a slice of the source state - * @returns A cached TanStackStoreSelector instance that subscribes to the source and applies the selector - */ - private _getOrCreateSelector = ( - source?: SubscribeSource, - selector?: (state: unknown) => unknown, - ): TanStackStoreSelector => { - if (!source) { - return new TanStackStoreSelector(this.host, () => source, selector) - } - - if (!this._selectorCache.has(source)) { - this._selectorCache.set(source, new Map()) - } - const selectorMap = this._selectorCache.get(source) - - // Get or create the selector for this source + selector combination - if (selectorMap?.has(selector)) { - return ( - selectorMap.get(selector) ?? - this.createSelectorForSource(source, selector) - ) - } - - const storeSelector = this.createSelectorForSource(source, selector) - selectorMap?.set(selector, storeSelector) - - return storeSelector - } - - /** - * Create a new TanStackStoreSelector for the given source and selector without caching. - * @param source The atom or store to subscribe to - * @param selector Optional selector function to select a slice of the source state - * @returns A new TanStackStoreSelector instance that subscribes to the source and applies the selector - */ - private createSelectorForSource = ( - source: SubscribeSource, - selector?: (state: unknown) => unknown, - ): TanStackStoreSelector => { - return new TanStackStoreSelector(this.host, () => source, selector) } } diff --git a/packages/lit-table/src/index.ts b/packages/lit-table/src/index.ts index 8b0b6659f7..bbc1d2b1b8 100755 --- a/packages/lit-table/src/index.ts +++ b/packages/lit-table/src/index.ts @@ -2,4 +2,5 @@ export * from '@tanstack/table-core' export * from './flexRender' export * from './TableController' +export * from './subscribe-directive' export * from './createTableHook' diff --git a/packages/lit-table/src/subscribe-directive.ts b/packages/lit-table/src/subscribe-directive.ts new file mode 100644 index 0000000000..0e3160e862 --- /dev/null +++ b/packages/lit-table/src/subscribe-directive.ts @@ -0,0 +1,174 @@ +import { TanStackStoreSelector } from '@tanstack/lit-store' +import { AsyncDirective, directive } from 'lit/async-directive.js' +import type { DirectiveResult } from 'lit/async-directive.js' +import type { + Atom, + ReadonlyAtom, + ReadonlyStore, + Store, +} from '@tanstack/lit-store' +import type { ReactiveControllerHost } from 'lit' + +export type SelectionSource = + | Atom + | ReadonlyAtom + | Store + | ReadonlyStore + +/** + * A function that selects a specific slice of state from the source. + * @template TSource - The complete state type from the store/atom. + * @template TSelected - The extracted or derived state type. + */ +type Selector = (state: TSource) => TSelected + +/** + * A render function that takes the selected state and returns content + * (typically a `TemplateResult`) to be rendered by Lit. + * @template TSelected - The selected state passed into the template. + */ +type TemplateFunction = (value: TSelected) => unknown + +/** + * A simple identity selector used when no specific selection is needed, + * allowing the directive to subscribe to the entire state of the source. + * @template T - The type of the state being passed through unchanged. + */ +const identitySelector = (state: T): T => state + +/** + * An asynchronous Lit directive that subscribes to a `@tanstack/lit-store` + * source and triggers re-renders specifically for the template portion it wraps. + * * It uses a "fake" `ReactiveControllerHost` to bridge the gap between + * TanStack's standard controller requirements and the `AsyncDirective` lifecycle. + */ +export class SubscribeDirective extends AsyncDirective { + /** The `TanStackStoreSelector` controller that manages the subscription to the store/atom */ + private controller?: TanStackStoreSelector + + /** The latest source and selector used to determine if a new subscription is needed on updates */ + private latestSource?: SelectionSource + /* The latest selector function used to determine if a new subscription is needed on updates */ + private latestSelector?: Selector + /* The latest resolved template function to render the selected state slice */ + private resolvedTemplate?: TemplateFunction + + /** + * Renders the entire state of the source without a selector. + * @param source - The store or atom to subscribe to. + * @param template - The render function receiving the full state. + */ + render( + source: SelectionSource, + template: TemplateFunction, + ): unknown + + /** + * Renders a specific slice of state derived via a selector function. + * @param source - The store or atom to subscribe to. + * @param selector - A function to extract the relevant slice of state. + * @param template - The render function receiving the selected state slice. + */ + render( + source: SelectionSource, + selector: Selector, + template: TemplateFunction, + ): unknown + + render( + source: SelectionSource, + selectorOrTemplate: Selector | TemplateFunction, + template?: TemplateFunction, + ) { + const isIdentitySubscription: boolean = template === undefined + + const selector = isIdentitySubscription + ? identitySelector + : (selectorOrTemplate as Selector) + + const actualTemplate = isIdentitySubscription + ? (selectorOrTemplate as TemplateFunction) + : template + + if (this.latestSelector !== selector) { + this.controller?.hostDisconnected() + this.controller = undefined + } + + this.latestSource = source + this.latestSelector = selector + this.resolvedTemplate = actualTemplate + + if (!this.controller) { + this.controller = new TanStackStoreSelector( + this.createFakeHost(), + () => this.latestSource, + (state) => this.latestSelector?.(state), + ) + } + + this.controller.hostUpdate() + + // TODO: update to newest version of Tanstack Store: https://github.com/TanStack/store/pull/329 + return this.resolvedTemplate?.(this.controller.value) + } + + /** Cleans up the controller subscription when the directive is removed from the DOM. */ + disconnected() { + this.controller?.hostDisconnected() + } + + /** Restores the controller subscription when the directive is re-attached to the DOM. */ + reconnected() { + this.controller?.hostUpdate() + } + + /** + * Creates a mock `ReactiveControllerHost` allowing the `TanStackStoreSelector` + * to plug into the `AsyncDirective`'s update cycle using `setValue()`. + */ + private createFakeHost(): ReactiveControllerHost { + return { + addController: () => {}, + removeController: () => {}, + requestUpdate: () => { + if (this.resolvedTemplate && this.controller) { + this.setValue(this.resolvedTemplate(this.controller.value)) + } + }, + get updateComplete() { + return Promise.resolve(true) + }, + } + } +} + +/** + * A Lit directive that subscribes to a source (Store or Atom) + * and efficiently updates only the wrapped template + * when the state or selected slice changes. + * @example + * ```ts + * // Without a selector (subscribes to entire state) + * html`
${subscribe(myStore, (state) => html`${state.count}`)}
` + * * // With a selector (only updates when `count` changes) + * html`
${subscribe(myStore, state => state.count, (count) => html`${count}`)}
` + * ``` + */ +export const subscribe = directive(SubscribeDirective) as { + /** Subscribes to the entire source state without filtering. */ + ( + source: SelectionSource, + template: TemplateFunction, + ): DirectiveResult + + /** + * Subscribes to a specific slice of the source state via a selector, + * preventing unnecessary re-renders when other parts of the state change. + */ + ( + source: SelectionSource, + selector: Selector, + template: TemplateFunction, + ): DirectiveResult +}