Short ADRs for the choices that shaped this foundation. Each is reversible — if you disagree, this is the list to argue with.
Context. Two shapes were on the table. A fat core, where pagination, retry,
the state machine, every observer, events, and plugins all live inside
@scrollstackjs/core and nothing outside it may hold logic. Or a constellation
of separate feature packages (virtual/, intersection/, paginator/,
persist/, …), which is how TanStack itself is built (@tanstack/query-core,
@tanstack/virtual-core).
Both can't be literally true: if paginator/ is its own package, pagination
isn't solely in core.
Decision. Constellation model, with a lean core that owns the engine
(state machine, fetch orchestration, retry, cancellation, events, plugins) and
defines contracts (Trigger, GetNextPageParam) rather than bundling every
feature. Optional capabilities (virtualization, persistence, pull-to-refresh,
devtools) become separate packages built on those contracts.
Why. It's the only way the headline < 5 KB core budget is reachable, and
it matches the ecosystem being modeled. Verified: the built core is
1.91 KB gzipped, the React adapter 0.32 KB — both well under budget.
A fat core with three observers + persistence + devtools inside it would blow
past 5 KB and couldn't be tree-shaken away by apps that don't use those parts.
@scrollstackjs/devtools shipped as a separate package on exactly this basis,
with no changes to core.
Consequence. Pagination is not four hardcoded strategies (cursor / offset /
page / time). It's one function — getNextPageParam — and the "strategies"
become presets/recipes on top. See ADR-002.
Decision. A single user-supplied function derives the next page parameter:
getNextPageParam(lastPage, allPages, lastPageParam, allPageParams)
=> TPageParam | null | undefined // null/undefined = no more pagesWhy. Cursor, offset/limit, and page-number pagination differ only in how
you compute the next param — not in engine behavior. Making that a data input
instead of a branch keeps the engine tiny and lets any pagination shape work,
including ones we didn't anticipate. (This is the TanStack Query model, chosen
deliberately.) All three strategies are exercised in pagination.test.ts against
the same engine.
Consequence. A 0 param is valid (offset 0) — the engine uses == null
checks, never truthiness. There's a regression test for exactly this.
Decision. State is modeled on two orthogonal axes:
status:idle | pending | success | error— describes the data.fetchStatus:idle | fetching— describes the network.
Convenience booleans (isLoading, isFetchingNextPage, isError, …) are
derived from these.
Why. "Is a request in flight?" and "do I have usable data?" are independent
questions. Collapsing them into one enum forces awkward states like a single
loading that can't distinguish first-load from load-more. Separating them is
what makes isFetchingNextPage (fetching and pages already exist) trivial and
correct.
Key rule — load-more failures keep your data. A first-load failure is a
true error (no data to show). A load-more failure keeps status: 'success'
(the pages you already loaded are still valid and render) but sets error so you
can show a retry affordance. Detect it with:
error !== null && pages.length > 0 && !isFetching. This is tested in
retry.test.ts and demonstrated in the React example.
Decision. The engine caches the snapshot object and returns the same
reference from getSnapshot() until state actually changes.
Why. React's useSyncExternalStore calls getSnapshot on every render and
bails out of re-rendering only if the reference is unchanged. Returning a fresh
object each call causes infinite re-renders. Caching makes the React adapter a
few lines with no memoization gymnastics. Tested directly (a === b until a real
change) in engine.test.ts.
Decision. Every fetch captures a monotonically increasing generation.
reset(), destroy(), and any superseding fetch bump it. A resolved fetch whose
generation no longer matches is discarded. In parallel, an AbortController
signal is passed to fetchPage and aborted on reset/destroy.
Why. The classic infinite-scroll bug is a stale response landing after a
reset and resurrecting dead state. The generation guard makes stale results
inert even if fetchPage ignores the abort signal; the signal lets
well-behaved fetchers cancel real network work. An abort is treated as a
cancellation, not a failure — it doesn't count toward retries. All covered in
concurrency.test.ts and cancellation.test.ts.
Decision. Core defines a Trigger interface and ships one default
implementation (createIntersectionTrigger), SSR-guarded so it returns null
without a DOM. Other triggers (scroll-event, manual, mutation-based) can be
separate packages implementing the same contract.
Why. IntersectionObserver is the 90% case and is tiny (~20 lines), so
shipping it keeps DX good — the engine works out of the box. Putting it behind a
contract means core isn't coupled to it, preserving the constellation model.
The engine no-ops cleanly on the server (returns idle, observeTarget does
nothing), verified in ssr.test.ts.
Decision. The monorepo is a pnpm workspace (pnpm-workspace.yaml), with
internal dependencies wired via the workspace:^ protocol — pnpm rewrites it to
^<version> at publish time, so a core patch reaches users without republishing
every adapter. pnpm -r runs
scripts in topological order, so pnpm -r build compiles the core before the
adapters that depend on it — no manual sequencing. Each package compiles and
type-checks with tsc (which also emits .d.ts). For publishing to npm, switch
each package's build script to tsup.
Verified on a current toolchain: TypeScript 7, Vitest 4, React 19, Vue 3, Svelte 5 — all dependencies installed at latest.
Why. pnpm's topological -r and strict linking make a multi-package build
correct by construction. tsc-emitted ESM uses extensionless relative imports,
which bundlers (and every test here) resolve fine, but raw Node ESM does not;
tsup (esbuild) resolves and bundles properly and enforces size budgets in CI.
Using tsc now keeps the toolchain zero-friction while the API stabilizes;
swapping in tsup is a one-line change per package when you're ready to publish.
Note:
.npmrcsetsnode-linker=hoistedfor maximum tool compatibility in this environment. Drop it to use pnpm's default isolatednode_modules(stricter, catches phantom dependencies) once the shared dev-tooling is settled.
Context. Three adapters now exist (React, Vue, Svelte) and must not each re-implement engine logic. ADR-001 keeps the core lean, but "lean" applies to features, not to the engine: the state machine, fetch orchestration, retry, and cancellation stay in one place, and an adapter that re-derived any of them would give every framework its own subtly different set of bugs.
Decision. Every adapter binds to exactly two engine methods — subscribe
(state changed) and getSnapshot (read current state) — and forwards the same
controls (loadNextPage, retry, reset) plus a sentinel binding. Nothing
framework-specific leaks into the core; nothing engine-specific is duplicated in
an adapter.
- React →
useSyncExternalStore(subscribe, getSnapshot, getSnapshot). The stable-snapshot guarantee (ADR-004) is exactly what this API needs. - Vue → a
shallowRefupdated insidesubscribe; teardown ononScopeDispose.:ref="target"is a Vue function ref. - Svelte → the returned object is a store: its
subscribecallsrunwith the current snapshot immediately (Svelte's contract) then on every change, so$scrollyields the snapshot.use:scroll.targetis a Svelte action.
Consequence — sentinel binding is where adapters genuinely differ. The three
frameworks do not agree on how often they invoke an element binding, and the
engine builds a new IntersectionObserver per observeTarget call. A fresh
observer reports its initial intersection immediately, so a binding that fires
more than once will refetch on every render — ignoring retry limits and looping
indefinitely. React's callback ref (stable identity) and Svelte's action both run
only on mount/unmount; Vue invokes function refs on every patch, so the Vue
adapter tracks the observed node and no-ops on repeats. Regression test:
observes the sentinel once, not on every re-render.
Why this matters. Each adapter is ~15–40 lines and carries no logic worth testing beyond "does it wire the framework's reactivity to the store." Adding Solid, Qwik, Preact, or Vanilla is now mechanical — bind those same two methods to that framework's reactivity primitive. The three shipped adapters are the proof the contract generalizes.
Context. Infinite scrolling and virtualization solve adjacent problems and are
routinely wanted together, which makes "add a virtual: true option to the engine"
the obvious move. It is the wrong one. The engine knows about pages — opaque
TData values it never looks inside — while a virtualizer knows about rows,
their pixel sizes, and a scroll container's geometry. Neither can be expressed in
the other's vocabulary, and half the people who want one do not want the other: a
static 50,000-row table has nothing to paginate.
Decision. @scrollstackjs/virtual is a separate package (ADR-001) exposing a
second store with the same contract as the engine — subscribe + getSnapshot,
referentially stable snapshots (ADR-004). Nothing in core changed to accommodate it.
The two are paired explicitly by connectInfiniteScroll(virtualizer, engine).
Why the same contract. The framework bindings were the test. Because the
virtualizer is shaped like the engine, useVirtualizer is useSyncExternalStore
again, the Vue binding is a shallowRef again, and the Svelte one satisfies the
store contract again — ~30 lines each, no new primitives, and ADR-008 holds for a
package that did not exist when it was written. They ship as /virtual entry points
on the existing adapters with @scrollstackjs/virtual as an optional peer, so an
app that never imports one pays nothing.
Consequence — the snapshot tracks the rendered window, not the scroll offset.
Scrolling fires continuously; the set of rows worth rendering does not. A snapshot
that carried scrollOffset would change on every frame of a fling and re-render the
list ~60 times a second for nothing. So the snapshot changes only when the window,
total size, or isScrolling changes, and the live offset is read on demand through
getScrollOffset(). This is the one place the virtualizer's contract deviates from
the engine's, and it is deliberate.
Consequence — the sentinel does not survive virtualization. observeTarget
needs an element in the DOM, and the element after the last row is exactly what a
virtual list declines to render. connectInfiniteScroll therefore triggers on
indices — the rendered window coming within threshold items of the end — and
takes over the first load as well, since nothing else would start it. It refuses to
re-ask while an error is pending (the engine's retry policy owns that, ADR-003) and
asks once per count, which is what keeps a scroll frame from becoming a fetch loop.
Consequence — a row with no layout box is not a 0px row. An element inside a
display: none subtree reports zero in both dimensions, and believing it collapses
the row, moves the window onto rows that are also hidden, and collapses those in
turn — a measure/render loop that never settles. Measurements are skipped unless the
element has a box in at least one dimension. Regression test:
keeps the estimate for a row that has no layout box yet.