Server-side windowed data (recipe)
For hundreds of thousands of rows: the component only computes which slice it needs; fetching, cancelling and merging are the host's job. This page gives a four-step recipe plus a runnable example (
oas-tabledemoed; gantt / resource / tree / list share the contract — differences below).
Contract
- Attributes:
windowed(enable) +total(full row count) +window-start(absolute row index ofdata[0]);datacarries only[window-start, window-start + data.length). - Event:
oas-window-requestwithdetail: { start, end, reason }(half-open, absolute row indices;reason=init/scroll/resize/data). The same expected window is never emitted twice; it resets after the window is covered. - Prerequisite: a measurable scroll viewport (
heightor a measurablemax-height); otherwise no request is emitted (onedevWarn). A windowed table slice must be flat (children are not recursed).
Four steps
- Fetch by segment: align requests to fixed segments (e.g. 200 rows) so one response covers many windows — typically an order of magnitude fewer requests.
- Keep only the latest: abort in-flight fetches (
AbortController) or drop stale responses with a request sequence number; otherwise an old response arriving late makes the view jump back. - Merge by id into the full dataset: edits/
oas-tasks-changecarry only the slice; the host must merge by id and never replace the full dataset wholesale. - Refill atomically: write
datafirst, thenwindow-start(within the same synchronous step) to avoid a "new slice + old start" intermediate state.
// Real project: segment fetch + latest-only + atomic refill
let seq = 0
let controller: AbortController | null = null
const SEG = 200
el.addEventListener('oas-window-request', async (event) => {
const { start, end } = (event as CustomEvent<{ start: number; end: number }>).detail
const my = ++seq
controller?.abort()
controller = new AbortController()
const segFrom = Math.floor(start / SEG)
const segTo = Math.floor((Math.max(start, end - 1)) / SEG)
const from = segFrom * SEG
const to = Math.min(total, (segTo + 1) * SEG)
const rows = await fetch(`/api/rows?from=${from}&to=${to}`, {
signal: controller.signal,
}).then((r) => r.json())
if (my !== seq) return // stale response, drop it
el.setAttribute('window-start', String(from))
el.setAttribute('data', JSON.stringify(rows))
})Runnable example (local mock server)
The demo simulates a server with local data: 200 rows per segment, 120ms latency, per-segment cache and latest-only. Scroll the table and watch the counters — scrolling within a loaded segment issues no new request (one response covers many windows).
windowed + segment cache + latest-only (mock 50k rows)
Waiting for the first window request…
Gantt / Resource windowed (host-managed axis)
Beyond row windows, timeline components support a host-managed axis via range-start / range-end; when the visible time window approaches an axis edge, oas-range-request is emitted so the host can extend the range. The two demos below show "segmented rows + extend the axis on demand".
Gantt (8000 tasks / 1-year axis / segments of 200)
gantt windowed + auto-extend the axis at the edge
Waiting for the first window request…
Resource (400 resources / half-year axis / segments of 50)
resource windowed + auto-extend the axis at the edge
Waiting for the first window request…
React / Vue host wrappers
Custom-element bridging notes: Vue needs isCustomElement: (tag) => tag.startsWith('oas-') in the compiler options; React 19+ supports custom elements natively (below 19, use a ref + addEventListener). Do not write attributes synchronously inside the event handler (it re-enters update) — refill after the fetch resolves.
React
import { useEffect, useRef } from 'react'
const SEG = 200
export function WindowedTable({ total }: { total: number }) {
const ref = useRef<HTMLElement | null>(null)
useEffect(() => {
const el = ref.current
if (!el) return
let seq = 0
let controller: AbortController | null = null
const onRequest = async (event: Event) => {
const { start, end } = (event as CustomEvent<{ start: number; end: number }>).detail
const my = ++seq
controller?.abort()
controller = new AbortController()
const from = Math.floor(start / SEG) * SEG
const to = Math.min(total, from + SEG)
const rows = await fetch(`/api/rows?from=${from}&to=${to}`, { signal: controller.signal })
.then((r) => r.json())
.catch(() => null)
if (!rows || my !== seq) return
el.setAttribute('data', JSON.stringify(rows))
el.setAttribute('window-start', String(from))
}
el.addEventListener('oas-window-request', onRequest)
return () => {
controller?.abort()
el.removeEventListener('oas-window-request', onRequest)
}
}, [total])
return (
<oas-table
ref={ref}
windowed=""
total={String(total)}
window-start="0"
height="320"
row-key="id"
columns={JSON.stringify([{ key: 'id', title: 'ID' }, { key: 'name', title: 'Name' }])}
/>
)
}TypeScript needs JSX intrinsic-element augmentation for custom elements (or
// @ts-expect-erroron the tag).
Vue 3
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
const SEG = 200
const table = ref<HTMLElement | null>(null)
let seq = 0
let controller: AbortController | null = null
async function onWindowRequest(event: Event) {
const el = table.value
if (!el) return
const { start, end } = (event as CustomEvent<{ start: number; end: number }>).detail
const my = ++seq
controller?.abort()
controller = new AbortController()
const from = Math.floor(start / SEG) * SEG
const to = Math.min(50000, from + SEG)
const rows = await fetch(`/api/rows?from=${from}&to=${to}`, { signal: controller.signal })
.then((r) => r.json())
.catch(() => null)
if (!rows || my !== seq) return
el.setAttribute('data', JSON.stringify(rows))
el.setAttribute('window-start', String(from))
}
onMounted(() => table.value?.addEventListener('oas-window-request', onWindowRequest))
onBeforeUnmount(() => {
controller?.abort()
table.value?.removeEventListener('oas-window-request', onWindowRequest)
})
</script>
<template>
<oas-table
ref="table"
windowed
total="50000"
window-start="0"
height="320"
row-key="id"
:columns="JSON.stringify([{ key: 'id', title: 'ID' }, { key: 'name', title: 'Name' }])"
/>
</template>In templates you can also write
@oas-window-request="onWindowRequest"(Vue 3 forwardsoas-window-requestas a custom event).
Component differences
| Component | windowed semantics | Extra prerequisites |
|---|---|---|
oas-table | row slice; total reused as the full row count | height; flat slice; local sort/filter/group/pagination are the host's job |
oas-gantt | row slice | height; flat slice; range-start/range-end for a host-managed axis; auto-schedule/critical path/row reorder skipped |
oas-resource | resource-row slice | height; range-start/range-end for a host-managed axis; load stats cover the loaded slice only |
oas-tree | root-node slice (children travel with the root) | height; local filter/check-all skipped; refill by widening |
oas-list | row slice | height or a measurable max-height; list semantics are owned by the component (no host role needed) |
gantt / resource also emit oas-range-request (detail: { startMs, endMs, reason }) when the visible time window approaches an axis edge — follow the same "latest-only + merge" rules.
Common pitfalls
- Refilling less than the expected window: the component keeps requesting. Return a segment covering
[start, end)(or a superset). - Replacing the whole dataset: edit write-backs (
oas-tasks-change) contain only the slice; a wholesale replace loses unloaded rows. - Forgetting
window-start: data and absolute indices drift (wrong rows rendered, wrong ARIA indices). - Writing other attributes synchronously in the handler (e.g.
loading): re-enters update (scrollTop may be transiently clamped to 0 mid-render, causing extra requests). Refill after fetching; if you need a loading state, set it in a microtask or after the fetch resolves. - Fetching per window: request count grows linearly with scrolling; segment fetching cuts it by an order of magnitude.
- No
height: no requests are emitted (no scroll viewport) — not a bug, the viewport must exist first. - Property writes before upgrade: before assigning properties such as
el.tasks/el.resourcesfrom page scripts,await customElements.whenDefined('oas-gantt')(or the matching tag) — on an unupgraded element the property becomes a plain own field that shadows the upgraded setter, silently losing data (the demos on this page already do this; the attribute channel avoids the issue).