Skip to content
Merged
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 docs/framework/alpine/guide/custom-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ The `getInitialState` method in a table feature is responsible for setting the d

#### initTableInstanceData and resetTableInstanceData

Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Every feature's initialization hook runs before any feature's `constructTableAPIs` hook.
Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Features are processed in a single pass in registration order; each feature's initialization hook runs just before that feature's `constructTableAPIs` hook, so hooks may rely on data and APIs from features registered earlier.

Use `resetTableInstanceData` to clear that transient data when `table.reset()` runs. Reset hooks run after internally owned table state atoms have been restored to `table.initialState`. They do not reset table state slices or externally controlled state, and `table.reset()` does not rerun `initTableInstanceData`.

Expand Down
2 changes: 1 addition & 1 deletion docs/framework/angular/guide/custom-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ The `getInitialState` method in a table feature is responsible for setting the d

##### initTableInstanceData and resetTableInstanceData

Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Every feature's initialization hook runs before any feature's `constructTableAPIs` hook.
Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Features are processed in a single pass in registration order; each feature's initialization hook runs just before that feature's `constructTableAPIs` hook, so hooks may rely on data and APIs from features registered earlier.

Use `resetTableInstanceData` to clear that transient data when `table.reset()` runs. Reset hooks run after internally owned table state atoms have been restored to `table.initialState`. They do not reset table state slices or externally controlled state, and `table.reset()` does not rerun `initTableInstanceData`.

Expand Down
2 changes: 1 addition & 1 deletion docs/framework/ember/guide/custom-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ The `getInitialState` method in a table feature is responsible for setting the d

##### initTableInstanceData and resetTableInstanceData

Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Every feature's initialization hook runs before any feature's `constructTableAPIs` hook.
Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Features are processed in a single pass in registration order; each feature's initialization hook runs just before that feature's `constructTableAPIs` hook, so hooks may rely on data and APIs from features registered earlier.

Use `resetTableInstanceData` to clear that transient data when `table.reset()` runs. Reset hooks run after internally owned table state atoms have been restored to `table.initialState`. They do not reset table state slices or externally controlled state, and `table.reset()` does not rerun `initTableInstanceData`.

Expand Down
2 changes: 1 addition & 1 deletion docs/framework/lit/guide/custom-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ The `getInitialState` method in a table feature is responsible for setting the d

#### initTableInstanceData and resetTableInstanceData

Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Every feature's initialization hook runs before any feature's `constructTableAPIs` hook.
Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Features are processed in a single pass in registration order; each feature's initialization hook runs just before that feature's `constructTableAPIs` hook, so hooks may rely on data and APIs from features registered earlier.

Use `resetTableInstanceData` to clear that transient data when `table.reset()` runs. Reset hooks run after internally owned table state atoms have been restored to `table.initialState`. They do not reset table state slices or externally controlled state, and `table.reset()` does not rerun `initTableInstanceData`.

Expand Down
2 changes: 1 addition & 1 deletion docs/framework/preact/guide/custom-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ The `getInitialState` method in a table feature is responsible for setting the d

##### initTableInstanceData and resetTableInstanceData

Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Every feature's initialization hook runs before any feature's `constructTableAPIs` hook.
Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Features are processed in a single pass in registration order; each feature's initialization hook runs just before that feature's `constructTableAPIs` hook, so hooks may rely on data and APIs from features registered earlier.

Use `resetTableInstanceData` to clear that transient data when `table.reset()` runs. Reset hooks run after internally owned table state atoms have been restored to `table.initialState`. They do not reset table state slices or externally controlled state, and `table.reset()` does not rerun `initTableInstanceData`.

Expand Down
2 changes: 1 addition & 1 deletion docs/framework/react/guide/custom-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ The `getInitialState` method in a table feature is responsible for setting the d

##### initTableInstanceData and resetTableInstanceData

Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Every feature's initialization hook runs before any feature's `constructTableAPIs` hook.
Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Features are processed in a single pass in registration order; each feature's initialization hook runs just before that feature's `constructTableAPIs` hook, so hooks may rely on data and APIs from features registered earlier.

Use `resetTableInstanceData` to clear that transient data when `table.reset()` runs. Reset hooks run after internally owned table state atoms have been restored to `table.initialState`. They do not reset table state slices or externally controlled state, and `table.reset()` does not rerun `initTableInstanceData`.

Expand Down
2 changes: 1 addition & 1 deletion docs/framework/solid/guide/custom-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ The `getInitialState` method in a table feature is responsible for setting the d

#### initTableInstanceData and resetTableInstanceData

Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Every feature's initialization hook runs before any feature's `constructTableAPIs` hook.
Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Features are processed in a single pass in registration order; each feature's initialization hook runs just before that feature's `constructTableAPIs` hook, so hooks may rely on data and APIs from features registered earlier.

Use `resetTableInstanceData` to clear that transient data when `table.reset()` runs. Reset hooks run after internally owned table state atoms have been restored to `table.initialState`. They do not reset table state slices or externally controlled state, and `table.reset()` does not rerun `initTableInstanceData`.

Expand Down
2 changes: 1 addition & 1 deletion docs/framework/svelte/guide/custom-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ The `getInitialState` method in a table feature is responsible for setting the d

#### initTableInstanceData and resetTableInstanceData

Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Every feature's initialization hook runs before any feature's `constructTableAPIs` hook.
Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Features are processed in a single pass in registration order; each feature's initialization hook runs just before that feature's `constructTableAPIs` hook, so hooks may rely on data and APIs from features registered earlier.

Use `resetTableInstanceData` to clear that transient data when `table.reset()` runs. Reset hooks run after internally owned table state atoms have been restored to `table.initialState`. They do not reset table state slices or externally controlled state, and `table.reset()` does not rerun `initTableInstanceData`.

Expand Down
2 changes: 1 addition & 1 deletion docs/framework/vue/guide/custom-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ The `getInitialState` method in a table feature is responsible for setting the d

#### initTableInstanceData and resetTableInstanceData

Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Every feature's initialization hook runs before any feature's `constructTableAPIs` hook.
Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Features are processed in a single pass in registration order; each feature's initialization hook runs just before that feature's `constructTableAPIs` hook, so hooks may rely on data and APIs from features registered earlier.

Use `resetTableInstanceData` to clear that transient data when `table.reset()` runs. Reset hooks run after internally owned table state atoms have been restored to `table.initialState`. They do not reset table state slices or externally controlled state, and `table.reset()` does not rerun `initTableInstanceData`.

Expand Down
23 changes: 13 additions & 10 deletions examples/lit/basic-subscribe/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,18 +118,25 @@ class LitTableExample extends LitElement {

private tableController = new TableController<typeof features, Person>(this)

private table = this.tableController.table(
{
// Options are re-synced on every render pass (like the other examples), so
// reactive inputs such as `this._data` flow into the table in the same
// update that renders them.
private tableOptions() {
return {
features,
columns,
data: this._data,
getRowId: (row) => row.id,
getRowId: (row: Person) => row.id,
enableRowSelection: true,
atoms: {
rowSelection: rowSelectionAtom,
},
debugTable: true,
},
}
}

private table = this.tableController.table(
this.tableOptions(),
() => null, // subscribe to no table state by default; use table.subscribe below
)

Expand All @@ -141,12 +148,6 @@ class LitTableExample extends LitElement {
pagination: state.pagination,
})

protected updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('_data')) {
this.table.setOptions((prev) => ({ ...prev, data: this._data }))
}
}

private renderColumnFilter(
context: HeaderContext<typeof features, Person, unknown>,
) {
Expand Down Expand Up @@ -211,6 +212,8 @@ class LitTableExample extends LitElement {
}

protected render() {
this.table = this.tableController.table(this.tableOptions(), () => null)

return html`
<div class="demo-root">
<div>
Expand Down
34 changes: 34 additions & 0 deletions examples/preact/basic-external-state/tests/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,37 @@ test('renders the table without crashing', async ({ page }) => {
await server.close()
}
})

test('updates controlled pagination without console errors', async ({
page,
}) => {
const { errors, server } = await openExample(page)

try {
const table = getTable(page)
const nextPageButton = page.getByRole('button', {
name: '>',
exact: true,
})
const pageStatus = page.getByText(/^Page$/).locator('..')

await expect(getBodyRows(table).first()).toBeVisible()
await expect(pageStatus).toContainText('1 of 100')
const firstPageRow = await getFirstBodyRowData(table)

await nextPageButton.click()

await expect(pageStatus).toContainText('2 of 100')
await expect.poll(() => getFirstBodyRowData(table)).not.toBe(firstPageRow)

const secondPageRow = await getFirstBodyRowData(table)

await nextPageButton.click()

await expect(pageStatus).toContainText('3 of 100')
await expect.poll(() => getFirstBodyRowData(table)).not.toBe(secondPageRow)
expect(errors).toEqual([])
} finally {
await server.close()
}
})
64 changes: 48 additions & 16 deletions packages/lit-table/src/TableController.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { constructTable } from '@tanstack/table-core'
import { createCommitFilteredSource } from '@tanstack/table-core/reactivity'
import { table_syncExternalStateToBaseAtoms } from '@tanstack/table-core/static-functions'
import { shallow } from '@tanstack/lit-store'
import { litReactivity } from './reactivity'
import { FlexRender } from './flexRender'
Expand Down Expand Up @@ -122,8 +124,14 @@ export class TableController<
host: ReactiveControllerHost

private _table: Table<TFeatures, TData> | null = null
private _rootSource?: {
get: () => TableState<TFeatures>
subscribe: (listener: (value: TableState<TFeatures>) => void) => {
unsubscribe: () => void
}
}
private _storeSubscription?: { unsubscribe: () => void }
private _optionsSubscription?: { unsubscribe: () => void }
private _capturedState?: Partial<TableState<TFeatures>>
private _notifier = 0
private _hasSelector = false
private _latestSelector?: (state: TableState<TFeatures>) => unknown
Expand Down Expand Up @@ -173,16 +181,34 @@ export class TableController<

this._table = constructTable(mergedOptions)

// The commit publication in hostUpdated() re-notifies `table.store` so
// `table.subscribe` islands update, but the host render already read
// that exact snapshot — forwarding the notification here would update
// the host once per controlled change just to find nothing changed.
// Only the controller's own subscription is filtered.
this._rootSource = createCommitFilteredSource<TableState<TFeatures>>(
this._table.store,
)

// Set up subscriptions immediately when table is created
this._setupSubscriptions()
}

// Update options when they change
// Update options when they change. The reactivity bindings declare
// `deferExternalStateSync`, so no store subscriber is notified while the
// host is still rendering; the readonly atoms expose fresh controlled
// state through their live get() in the meantime. Publication happens in
// hostUpdated().
this._table.setOptions((prev) => ({
...prev,
...tableOptions,
}))

// Capture this render's controlled state: `table.options` is a shared
// mutable object, and by the time hostUpdated() runs it may hold values
// from a newer render pass.
this._capturedState = this._table.options.state

// Record the latest selector each render pass and re-baseline what the
// store-subscription gate compares against, so renders triggered by
// anything else (e.g. a @state change) reset the gate too. The gate lets
Expand All @@ -193,31 +219,30 @@ export class TableController<
| ((state: TableState<TFeatures>) => unknown)
| undefined
this._lastSelected = selector
? selector(this._table.store.state)
? selector(this._rootSource!.get())
: undefined

// Capture for closure
const tableInstance = this._table
const rootSource = this._rootSource!

return {
...this._table,
subscribe,
FlexRender,
get state() {
return (selector?.(tableInstance.store.state) ??
tableInstance.store.state) as TSelected
return (selector?.(rootSource.get()) ?? rootSource.get()) as TSelected
},
} as unknown as LitTable<TFeatures, TData, TSelected>
}

private _setupSubscriptions() {
if (this._table && !this._storeSubscription) {
this._storeSubscription = this._table.store.subscribe(() => {
this._storeSubscription = this._rootSource!.subscribe((state) => {
// With a selector, only update the host when the selected state
// actually changes (shallow compare). No selector keeps the previous
// behavior of updating on every state change.
if (this._hasSelector) {
const nextSelected = this._latestSelector!(this._table!.store.state)
const nextSelected = this._latestSelector!(state)
if (shallow(this._lastSelected as any, nextSelected as any)) {
return
}
Expand All @@ -226,23 +251,30 @@ export class TableController<
this._notifier++
this.host.requestUpdate()
})

// Options changes (e.g. new data) must always re-render.
this._optionsSubscription = this._table.optionsStore!.subscribe(() => {
this._notifier++
this.host.requestUpdate()
})
}
}

hostConnected() {
this._setupSubscriptions()
}

hostUpdated() {
if (!this._table) {
return
}
// Publish the controlled state captured by the committed render so
// `table.subscribe` islands and other store subscribers observe it. Core
// batches the writes and bumps the commit version, which also handles
// changes in controlled/uncontrolled ownership.
table_syncExternalStateToBaseAtoms(
this._table,
this._capturedState ?? null,
shallow,
)
}

hostDisconnected() {
this._storeSubscription?.unsubscribe()
this._storeSubscription = undefined
this._optionsSubscription?.unsubscribe()
this._optionsSubscription = undefined
}
}
Loading
Loading