Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/lit-table/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"build": "tsdown"
},
"dependencies": {
"@tanstack/store": "^0.11.0",
"@tanstack/lit-store": "^0.13.2",
"@tanstack/table-core": "workspace:*"
},
"devDependencies": {
Expand Down
94 changes: 27 additions & 67 deletions packages/lit-table/src/TableController.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,16 @@
import { constructTable } from '@tanstack/table-core'
import { litReactivity } from './reactivity'
import { FlexRender } from './flexRender'
import type { Atom, ReadonlyAtom, ReadonlyStore, Store } from '@tanstack/store'

import { subscribe } from './subscribe-directive'
import type {
NoInfer,
RowData,
Table,
TableFeatures,
TableOptions,
TableState,
} from '@tanstack/table-core'
import type {
ReactiveController,
ReactiveControllerHost,
TemplateResult,
} from 'lit'

export type SubscribeSource<TValue> =
| Atom<TValue>
| ReadonlyAtom<TValue>
| Store<TValue>
| ReadonlyStore<TValue>
import type { ReactiveController, ReactiveControllerHost } from 'lit'

/**
* The extended table type returned by the Lit adapter.
Expand All @@ -40,45 +30,34 @@ export type LitTable<
*/
readonly store: Table<TFeatures, TData>['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`<div>${JSON.stringify(state)}</div>`,
* })
* // 1. Subscribe to a specific state slice (re-renders ONLY when rowSelection changes)
* html`
* <div>
* ${table.subscribe(
* table.store,
* (state) => state.rowSelection,
* (rowSelection) => html`<span>Selected: ${JSON.stringify(rowSelection)}</span>`
* )}
* </div>
* `
*
* // 2. Subscribe to the full state (re-renders on any state mutation)
* html`
* <div>
* ${table.subscribe(
* table.store,
* (state) => html`<span>Total rows: ${state.rowModel.rows.length}</span>`
* )}
* </div>
* `
* ```
*/
Subscribe: {
<TSourceValue>(props: {
source: SubscribeSource<TSourceValue>
selector?: undefined
children:
| ((state: Readonly<TSourceValue>) => TemplateResult | string)
| TemplateResult
| string
}): TemplateResult | string
<TSourceValue, TSubscribeSelected>(props: {
source: SubscribeSource<TSourceValue>
selector: (state: TSourceValue) => TSubscribeSelected
children:
| ((state: Readonly<TSubscribeSelected>) => TemplateResult | string)
| TemplateResult
| string
}): TemplateResult | string
<TSubscribeSelected>(props: {
selector: (state: NoInfer<TableState<TFeatures>>) => TSubscribeSelected
children:
| ((state: Readonly<TSubscribeSelected>) => 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
Expand Down Expand Up @@ -204,28 +183,9 @@ export class TableController<
// Capture for closure
const tableInstance = this._table

// Attach Subscribe function
const Subscribe = function Subscribe(props: {
source?: SubscribeSource<unknown>
selector?: (state: unknown) => unknown
children:
| ((state: Readonly<unknown>) => 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
if (typeof props.children === 'function') {
return props.children(selectedState as Readonly<unknown>)
}
return props.children
} as LitTable<TFeatures, TData, TSelected>['Subscribe']

return {
...this._table,
Subscribe,
subscribe,
FlexRender,
get state() {
return (selector?.(tableInstance.store.state) ??
Expand Down
1 change: 1 addition & 0 deletions packages/lit-table/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from '@tanstack/table-core'

export * from './flexRender'
export * from './TableController'
export * from './subscribe-directive'
export * from './createTableHook'
2 changes: 1 addition & 1 deletion packages/lit-table/src/reactivity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { batch, createAtom } from '@tanstack/store'
import { batch, createAtom } from '@tanstack/lit-store'
import type {
TableAtomOptions,
TableReactivityBindings,
Expand Down
174 changes: 174 additions & 0 deletions packages/lit-table/src/subscribe-directive.ts
Original file line number Diff line number Diff line change
@@ -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<TValue> =
| Atom<TValue>
| ReadonlyAtom<TValue>
| Store<TValue>
| ReadonlyStore<TValue>

/**
* 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<TSource, TSelected> = (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<TSelected> = (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 = <T>(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<any, any>

/** The latest source and selector used to determine if a new subscription is needed on updates */
private latestSource?: SelectionSource<any>
/* The latest selector function used to determine if a new subscription is needed on updates */
private latestSelector?: Selector<any, any>
/* The latest resolved template function to render the selected state slice */
private resolvedTemplate?: TemplateFunction<any>

/**
* 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<TSource>(
source: SelectionSource<TSource>,
template: TemplateFunction<TSource>,
): 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<TSource, TSelected>(
source: SelectionSource<TSource>,
selector: Selector<TSource, TSelected>,
template: TemplateFunction<TSelected>,
): unknown

render(
source: SelectionSource<any>,
selectorOrTemplate: Selector<any, any> | TemplateFunction<any>,
template?: TemplateFunction<any>,
) {
const isIdentitySubscription: boolean = template === undefined

const selector = isIdentitySubscription
? identitySelector
: (selectorOrTemplate as Selector<any, any>)

const actualTemplate = isIdentitySubscription
? (selectorOrTemplate as TemplateFunction<any>)
: 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`<div>${subscribe(myStore, (state) => html`<span>${state.count}</span>`)}</div>`
* * // With a selector (only updates when `count` changes)
* html`<div>${subscribe(myStore, state => state.count, (count) => html`<span>${count}</span>`)}</div>`
* ```
*/
export const subscribe = directive(SubscribeDirective) as {
/** Subscribes to the entire source state without filtering. */
<TSource>(
source: SelectionSource<TSource>,
template: TemplateFunction<TSource>,
): DirectiveResult<typeof SubscribeDirective>

/**
* Subscribes to a specific slice of the source state via a selector,
* preventing unnecessary re-renders when other parts of the state change.
*/
<TSource, TSelected>(
source: SelectionSource<TSource>,
selector: Selector<TSource, TSelected>,
template: TemplateFunction<TSelected>,
): DirectiveResult<typeof SubscribeDirective>
}
16 changes: 13 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.