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
6 changes: 6 additions & 0 deletions .changeset/app-wrapper-live-cells.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/svelte-table': patch
'@tanstack/vue-table': patch
---

Keep `AppCell`, `AppHeader`, and `AppFooter` in sync with their `cell`/`header` prop. When `data` or `columns` are replaced, a keyed `{#each}` or `v-for` reuses the wrapper with a new instance that has the same id. The wrapper kept the first instance, so the snippet/slot value and `useCellContext()`/`useHeaderContext()` showed stale values. The wrappers now read the current prop, and the context forwards to it.
12 changes: 9 additions & 3 deletions packages/svelte-table/src/AppCell.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { setContext, untrack } from 'svelte'
import { setContext } from 'svelte'
import { cellContextKey } from './context-keys'
import { createLiveProxy } from './live-proxy'
import type { Snippet } from 'svelte'

interface Props {
Expand All @@ -11,10 +12,15 @@

let { children, cell, cellComponents }: Props = $props()

const extendedCell = $derived(Object.assign(cell, cellComponents))

// A keyed `{#each}` reuses this component when `data` is replaced, passing
// a new cell with the same id. The context forwards to the current cell so
// consumers never read the one this component was created with.
setContext(
cellContextKey,
untrack(() => cell),
createLiveProxy(() => extendedCell),
)
</script>

{@render children?.(Object.assign(cell, cellComponents))}
{@render children?.(extendedCell)}
12 changes: 9 additions & 3 deletions packages/svelte-table/src/AppHeader.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { setContext, untrack } from 'svelte'
import { setContext } from 'svelte'
import { headerContextKey } from './context-keys'
import { createLiveProxy } from './live-proxy'
import type { Snippet } from 'svelte'

interface Props {
Expand All @@ -11,10 +12,15 @@

let { children, header, headerComponents }: Props = $props()

const extendedHeader = $derived(Object.assign(header, headerComponents))

// A keyed `{#each}` reuses this component when `data` is replaced, passing
// a new header with the same id. The context forwards to the current header so
// consumers never read the one this component was created with.
setContext(
headerContextKey,
untrack(() => header),
createLiveProxy(() => extendedHeader),
)
</script>

{@render children?.(Object.assign(header, headerComponents))}
{@render children?.(extendedHeader)}
45 changes: 32 additions & 13 deletions packages/svelte-table/src/createTableHook.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,8 +577,8 @@ export function createTableHook<
}

// `<table.AppCell>` Object.assign-es `cellComponents` and `FlexRender` onto
// the same cell instance it puts in context, so this asserts the runtime
// shape.
// its cell and puts a live view of the current cell in context, so this
// asserts the runtime shape.
return cell as unknown as Cell<TFeatures, any, TValue> &
TCellComponents & { FlexRender: typeof FlexRenderSvelte }
}
Expand All @@ -603,7 +603,7 @@ export function createTableHook<
}

// `<table.AppHeader>` / `<table.AppFooter>` Object.assign `headerComponents`
// and `FlexRender` onto the same header instance they put in context.
// and `FlexRender` onto their header and put a live view of it in context.
return header as unknown as Header<TFeatures, any, TValue> &
THeaderComponents & { FlexRender: typeof FlexRenderSvelte }
}
Expand Down Expand Up @@ -650,38 +650,57 @@ export function createTableHook<
// They only add props: each App*Svelte component sets its own context while
// it initializes. Calling setContext here would run before that component
// exists and write into the context of the component rendering it.
// Props are forwarded through getters: Svelte props are getters, and reading
// them here would pass the first value to the inner component forever.
const AppTable = ((internal: any, props: any) => {
return AppTableSvelte(internal, { ...props, table })
return AppTableSvelte(internal, {
get children() {
return props.children
},
table,
})
}) as Component<{ children: Snippet }>

const AppCell = ((internal: any, { children, cell }: any) => {
const AppCell = ((internal: any, props: any) => {
return AppCellSvelte(internal, {
cell,
get cell() {
return props.cell
},
cellComponents: cellComponentsWithFlexRender,
children,
get children() {
return props.children
},
})
}) as Component<{
cell: Cell<TFeatures, TData, any>
children: Snippet<[any]>
}>

const AppHeader = ((internal: any, { children, header }: any) => {
const AppHeader = ((internal: any, props: any) => {
return AppHeaderSvelte(internal, {
header,
get header() {
return props.header
},
headerComponents: headerComponentsWithFlexRender,
children,
get children() {
return props.children
},
})
}) as Component<{
header: Header<TFeatures, TData, any>
children: Snippet<[any]>
}>

// AppFooter reuses AppHeaderSvelte (footers use Header type in table-core)
const AppFooter = ((internal: any, { children, header }: any) => {
const AppFooter = ((internal: any, props: any) => {
return AppHeaderSvelte(internal, {
header,
get header() {
return props.header
},
headerComponents: headerComponentsWithFlexRender,
children,
get children() {
return props.children
},
})
}) as Component<{
header: Header<TFeatures, TData, any>
Expand Down
50 changes: 50 additions & 0 deletions packages/svelte-table/src/live-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Creates a stable object that forwards every read to `getTarget()`.
*
* `App*` wrappers provide a cell or header through context once, while the
* instance behind them can change (e.g. `data` is replaced and a keyed
* `{#each}` reuses the component with a new cell). Reading `getTarget()` inside
* a reactive scope also tracks the source prop, so consumers update with it.
*
* Prototype methods are called with the current instance as `this`, so
* per-instance memos never see the proxy. The forwarders are cached per key so
* method identity stays stable across target changes.
*/
export function createLiveProxy<T extends object>(getTarget: () => T): T {
const forwarders = new Map<PropertyKey, (...args: Array<any>) => any>()

return new Proxy({} as T, {
get(_, key) {
const target = getTarget()
const value = Reflect.get(target, key, target)
if (typeof value !== 'function' || Object.hasOwn(target, key)) {
return value
}
let forwarder = forwarders.get(key)
if (!forwarder) {
forwarder = function (...args) {
const current = getTarget() as any
return current[key](...args)
}
forwarders.set(key, forwarder)
}
return forwarder
},
set(_, key, value) {
return Reflect.set(getTarget(), key, value)
},
has(_, key) {
return key in getTarget()
},
ownKeys() {
return Reflect.ownKeys(getTarget())
},
getOwnPropertyDescriptor(_, key) {
const descriptor = Reflect.getOwnPropertyDescriptor(getTarget(), key)
return descriptor && { ...descriptor, configurable: true }
},
getPrototypeOf() {
return Reflect.getPrototypeOf(getTarget())
},
})
}
47 changes: 47 additions & 0 deletions packages/svelte-table/tests/fixtures/HookDataRefresh.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<script lang="ts">
import HookHeaderLabel from './HookHeaderLabel.svelte'
import { hook } from './hook-fixture'
import type { HookData } from './hook-fixture'

const columnHelper = hook.createAppColumnHelper<HookData>()
function titleColumns(label: string) {
return columnHelper.columns([
columnHelper.accessor('title', { header: 'Title', meta: { label } }),
])
}

let data = $state<Array<HookData>>([{ id: '1', title: 'First' }])
let columns = $state(titleColumns('Before'))
const table = hook.createAppTable({
get data() {
return data
},
get columns() {
return columns
},
})
</script>

<button onclick={() => (data = [{ id: '1', title: 'Second' }])}>
Replace data
</button>
<button onclick={() => (columns = titleColumns('After'))}>
Replace columns
</button>

{#each table.getHeaderGroups()[0]!.headers as header (header.id)}
<table.AppHeader {header}>
<HookHeaderLabel />
</table.AppHeader>
{/each}

{#each table.getRowModel().rows as row (row.id)}
{#each row.getAllCells() as cell (cell.id)}
<table.AppCell {cell}>
{#snippet children(value)}
<output aria-label="Snippet cell">{value.getValue()}</output>
<value.CellBadge />
{/snippet}
</table.AppCell>
{/each}
{/each}
10 changes: 10 additions & 0 deletions packages/svelte-table/tests/fixtures/HookHeaderLabel.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<script lang="ts">
import { hook } from './hook-fixture'

const header = hook.useHeaderContext<string>()
</script>

<span
>header-label:{(header.column.columnDef.meta as { label: string })
.label}</span
>
20 changes: 20 additions & 0 deletions packages/svelte-table/tests/rendering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { renderComponent } from '../src/render-component'
import ContextFailure from './fixtures/ContextFailure.svelte'
import FlexRenderHarness from './fixtures/FlexRenderHarness.svelte'
import HookContextScope from './fixtures/HookContextScope.svelte'
import HookDataRefresh from './fixtures/HookDataRefresh.svelte'
import HookHarness from './fixtures/HookHarness.svelte'
import RenderBadge from './fixtures/RenderBadge.svelte'
import { hook } from './fixtures/hook-fixture'
Expand Down Expand Up @@ -220,6 +221,25 @@ describe('createTableHook', () => {
expect(outputText('Contexts outside the wrappers')).toBe('')
})

test('keyed wrappers follow new cells and headers with the same id', async () => {
render(HookDataRefresh)

expect(outputText('Snippet cell')).toBe('First')
expect(screen.getByText('cell-component:First')).toBeTruthy()
expect(screen.getByText('header-label:Before')).toBeTruthy()

await fireEvent.click(screen.getByRole('button', { name: 'Replace data' }))

expect(outputText('Snippet cell')).toBe('Second')
expect(screen.getByText('cell-component:Second')).toBeTruthy()

await fireEvent.click(
screen.getByRole('button', { name: 'Replace columns' }),
)

expect(screen.getByText('header-label:After')).toBeTruthy()
})

test.each([
['useTableContext', () => hook.useTableContext()],
['useCellContext', () => hook.useCellContext()],
Expand Down
Loading
Loading