Skip to content

Commit d7d2db1

Browse files
committed
feat: add scroll helper
1 parent da165fd commit d7d2db1

File tree

9 files changed

+615
-63
lines changed

9 files changed

+615
-63
lines changed

factories/inertia_factory.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,18 @@
77
* file that was distributed with this source code.
88
*/
99

10-
import { type Vite } from '@adonisjs/vite'
11-
import { type HttpContext } from '@adonisjs/core/http'
1210
import { HttpContextFactory } from '@adonisjs/core/factories/http'
11+
import { type HttpContext } from '@adonisjs/core/http'
12+
import { type Vite } from '@adonisjs/vite'
1313

1414
import { defineConfig } from '../index.js'
15-
import { Inertia } from '../src/inertia.js'
1615
import { InertiaHeaders } from '../src/headers.js'
16+
import { Inertia } from '../src/inertia.js'
1717
import { ServerRenderer } from '../src/server_renderer.js'
1818
import {
1919
type AssetsVersion,
20-
type InertiaConfig,
2120
type ComponentProps,
21+
type InertiaConfig,
2222
type InertiaConfigInput,
2323
} from '../src/types.js'
2424

@@ -95,6 +95,11 @@ export class InertiaFactory<Pages extends Record<string, ComponentProps>> {
9595
return this
9696
}
9797

98+
scrollIntent(intent: 'append' | 'prepend') {
99+
this.#parameters.ctx.request.request.headers[InertiaHeaders.InfiniteScrollMergeIntent] = intent
100+
return this
101+
}
102+
98103
/**
99104
* Removes the X-Inertia header from the request headers
100105
*

src/headers.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,10 @@ export const InertiaHeaders = {
7474
* Used to identify which component is being partially reloaded.
7575
*/
7676
PartialComponent: 'x-inertia-partial-component',
77+
78+
/**
79+
* Header sent to indicate how the incoming page of data should be combined with existing client-side data.
80+
* Value is "append", "prepend", or absent on initial load.
81+
*/
82+
InfiniteScrollMergeIntent: 'x-inertia-infinite-scroll-merge-intent',
7783
} as const

src/inertia.ts

Lines changed: 58 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,32 +10,32 @@
1010
/// <reference types="@adonisjs/core/providers/app_provider" />
1111
/// <reference types="@adonisjs/core/providers/edge_provider" />
1212

13-
import { createHash } from 'node:crypto'
14-
import { type Vite } from '@adonisjs/vite'
1513
import type { HttpContext } from '@adonisjs/core/http'
16-
14+
import { type Vite } from '@adonisjs/vite'
15+
import { type AsyncOrSync } from '@poppinss/utils/types'
16+
import { createHash } from 'node:crypto'
17+
import debug from './debug.ts'
1718
import { InertiaHeaders } from './headers.js'
19+
import {
20+
always,
21+
buildPartialRequestProps,
22+
buildStandardVisitProps,
23+
deepMerge,
24+
defer,
25+
merge,
26+
optional,
27+
scroll,
28+
} from './props.ts'
1829
import { type ServerRenderer } from './server_renderer.js'
1930
import type {
20-
PageProps,
21-
PageObject,
2231
AsPageProps,
23-
RequestInfo,
24-
InertiaConfig,
2532
ComponentProps,
33+
InertiaConfig,
34+
PageObject,
35+
PageProps,
36+
RequestInfo,
2637
SharedProps,
2738
} from './types.js'
28-
import {
29-
defer,
30-
merge,
31-
always,
32-
optional,
33-
deepMerge,
34-
buildStandardVisitProps,
35-
buildPartialRequestProps,
36-
} from './props.ts'
37-
import debug from './debug.ts'
38-
import { type AsyncOrSync } from '@poppinss/utils/types'
3939

4040
/**
4141
* Main class used to interact with Inertia
@@ -146,6 +146,18 @@ export class Inertia<Pages> {
146146
*/
147147
deepMerge = deepMerge
148148

149+
/**
150+
* Wrap a paginated value for the infinite scroll
151+
*
152+
* @example
153+
* ```js
154+
* {
155+
* posts: inertia.scroll(() => PostTransformer.paginate(posts.all(), posts.getMeta()))
156+
* }
157+
* ```
158+
*/
159+
scroll = scroll
160+
149161
/**
150162
* Creates a new Inertia instance
151163
*
@@ -241,6 +253,7 @@ export class Inertia<Pages> {
241253
finalProps = { ...pageProps }
242254
}
243255

256+
let result
244257
if (requestInfo.partialComponent === component) {
245258
const only = requestInfo.onlyProps
246259
const except = requestInfo.exceptProps ?? []
@@ -254,11 +267,30 @@ export class Inertia<Pages> {
254267
debug('building props for a partial reload %O', requestInfo)
255268
debug('cherry picking props %s', cherryPickProps)
256269

257-
return buildPartialRequestProps(finalProps, cherryPickProps, this.ctx.containerResolver)
270+
result = await buildPartialRequestProps(
271+
finalProps,
272+
cherryPickProps,
273+
this.ctx.containerResolver
274+
)
275+
} else {
276+
debug('building props for a standard visit %O', requestInfo)
277+
result = await buildStandardVisitProps(finalProps, this.ctx.containerResolver)
278+
}
279+
280+
const prependProps: string[] = []
281+
const mergeProps: string[] = [...(result.mergeProps || [])]
282+
const scrollProps = result.scrollProps ?? {}
283+
for (const [key, propInfo] of Object.entries(scrollProps)) {
284+
const scrolPropPath = `${key}.${propInfo.wrapper}`
285+
286+
if (requestInfo.scrollMergeIntent === 'prepend') {
287+
prependProps.push(scrolPropPath)
288+
} else if (requestInfo.scrollMergeIntent === 'append') {
289+
mergeProps.push(scrolPropPath)
290+
}
258291
}
259292

260-
debug('building props for a standard visit %O', requestInfo)
261-
return buildStandardVisitProps(finalProps, this.ctx.containerResolver)
293+
return { ...result, mergeProps, prependProps }
262294
}
263295

264296
/**
@@ -350,6 +382,7 @@ export class Inertia<Pages> {
350382
exceptProps: this.ctx.request.header(InertiaHeaders.PartialExcept)?.split(','),
351383
resetProps: this.ctx.request.header(InertiaHeaders.Reset)?.split(','),
352384
errorBag: this.ctx.request.header(InertiaHeaders.ErrorBag),
385+
scrollMergeIntent: this.ctx.request.header(InertiaHeaders.InfiniteScrollMergeIntent),
353386
}
354387

355388
return this.#cachedRequestInfo
@@ -466,11 +499,8 @@ export class Inertia<Pages> {
466499
: never
467500
): Promise<PageObject<Pages[Page]>> {
468501
const requestInfo = this.requestInfo()
469-
const { props, mergeProps, deferredProps, deepMergeProps } = await this.#buildPageProps(
470-
page,
471-
requestInfo,
472-
pageProps
473-
)
502+
const { props, mergeProps, deferredProps, deepMergeProps, prependProps, scrollProps } =
503+
await this.#buildPageProps(page, requestInfo, pageProps)
474504

475505
return {
476506
component: page,
@@ -482,6 +512,8 @@ export class Inertia<Pages> {
482512
deferredProps,
483513
mergeProps,
484514
deepMergeProps,
515+
prependProps,
516+
scrollProps,
485517
} satisfies PageObject<Pages[Page]>
486518
}
487519

src/props.ts

Lines changed: 125 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,26 @@ import { BaseSerializer } from '@adonisjs/core/transformers'
1111
import { type AsyncOrSync } from '@adonisjs/core/types/common'
1212
import { type JSONDataTypes } from '@adonisjs/core/types/transformers'
1313

14-
import { ALWAYS_PROP, DEEP_MERGE, DEFERRED_PROP, OPTIONAL_PROP, TO_BE_MERGED } from './symbols.ts'
14+
import { type ContainerResolver } from '@adonisjs/core/container'
15+
import {
16+
ALWAYS_PROP,
17+
DEEP_MERGE,
18+
DEFERRED_PROP,
19+
OPTIONAL_PROP,
20+
SCROLL_PROP,
21+
TO_BE_MERGED,
22+
} from './symbols.ts'
1523
import {
16-
type DeferProp,
17-
type PageProps,
1824
type AlwaysProp,
19-
type OptionalProp,
20-
type MergeableProp,
2125
type ComponentProps,
26+
type DeferProp,
27+
type MergeableProp,
28+
type OptionalProp,
29+
type PageProps,
30+
type ScrollMetadata,
31+
type ScrollProp,
2232
type UnPackedPageProps,
2333
} from './types.ts'
24-
import { type ContainerResolver } from '@adonisjs/core/container'
2534

2635
class InertiaSerializer extends BaseSerializer {
2736
wrap: undefined = undefined
@@ -215,6 +224,39 @@ export function deepMerge<T extends UnPackedPageProps | DeferProp<UnPackedPagePr
215224
}
216225
}
217226

227+
/**
228+
* Wraps a paginated value for infinite scrolling.
229+
*
230+
* Pagination metadata is automatically extracted and emitted in scrollProps.
231+
* Merge/prepend behavior is wired based on the X-Inertia-Infinite-Scroll-Merge-Intent
232+
* request header: "append" adds propKey.data to mergeProps, "prepend" to prependProps,
233+
* absent means initial load and no merge is declared.
234+
*
235+
* @example
236+
* ```js
237+
* { posts: inertia.scroll(() => PostTransformer.paginate(posts.all(), posts.getMeta())) }
238+
* ```
239+
*
240+
* @example Custom page query parameter
241+
* ```js
242+
* {
243+
* users: inertia.scroll(() => UserTransformer.paginate(users.all(), users.getMeta()), { pageName: 'usersPage' }),
244+
* orders: inertia.scroll(() => OrderTransformer.paginate(orders.all(), orders.getMeta()), { pageName: 'ordersPage' }),
245+
* }
246+
* ```
247+
*/
248+
export function scroll<T extends UnPackedPageProps>(
249+
value: T | (() => AsyncOrSync<T>),
250+
options: { pageName?: string; wrapper?: string } = {}
251+
): ScrollProp<T> {
252+
return {
253+
value,
254+
pageName: options.pageName ?? 'page',
255+
wrapper: options.wrapper ?? 'data',
256+
[SCROLL_PROP]: true,
257+
}
258+
}
259+
218260
/**
219261
* Type guard that checks if a prop value is a deferred prop.
220262
*
@@ -315,6 +357,50 @@ export function isOptionalProp<T extends UnPackedPageProps>(
315357
return OPTIONAL_PROP in propValue
316358
}
317359

360+
/**
361+
* Type guard that checks if a prop value is a scroll prop.
362+
*
363+
* Scroll props contain the SCROLL_PROP symbol and wrap a paginated value
364+
* for the infinite scroll.
365+
*
366+
* @param propValue - The object to check for scroll prop characteristics
367+
* @returns True if the prop value is a scroll prop
368+
*
369+
* @example
370+
* ```js
371+
* const prop = scroll(() => PostTransformer.paginate(posts.all(), posts.getMeta()))
372+
*
373+
* if (isScrollProp(prop)) {
374+
* // prop is now typed as ScrollProp<T>
375+
* const result = await prop.compute()
376+
* }
377+
* ```
378+
*/
379+
export function isScrollProp<T extends UnPackedPageProps>(
380+
propValue: Object
381+
): propValue is ScrollProp<T> {
382+
return SCROLL_PROP in propValue
383+
}
384+
385+
/**
386+
* Extracts scroll metadata from a serialized paginator value.
387+
* Reads from the "metadata" key produced by AdonisJS transformers
388+
* rather than inspecting the raw paginator instance.
389+
*/
390+
function resolveScrollMetadata(serialized: any, pageName: string, wrapper: string): ScrollMetadata {
391+
const meta = serialized?.metadata ?? {}
392+
const currentPage: number | null = meta.currentPage ?? null
393+
const lastPage: number | null = meta.lastPage ?? null
394+
return {
395+
pageName,
396+
wrapper,
397+
currentPage,
398+
nextPage:
399+
currentPage !== null && lastPage !== null && currentPage < lastPage ? currentPage + 1 : null,
400+
previousPage: currentPage !== null && currentPage > 1 ? currentPage - 1 : null,
401+
}
402+
}
403+
318404
/**
319405
* Helper function to unpack prop values using the transformer serialize function.
320406
*
@@ -361,6 +447,7 @@ export async function buildStandardVisitProps(
361447
const deepMergeProps: string[] = []
362448
const newProps: ComponentProps = {}
363449
const deferredProps: { [group: string]: string[] } = {}
450+
const scrollProps: { [key: string]: ScrollMetadata } = {}
364451
const unpackedValues: Array<{
365452
key: string
366453
value: UnPackedPageProps | (() => AsyncOrSync<UnPackedPageProps>)
@@ -393,6 +480,15 @@ export async function buildStandardVisitProps(
393480
continue
394481
}
395482

483+
/**
484+
* Scroll props are serialized like standard props. The pageName is tracked
485+
* so that scrollProps metadata can be derived after serialization.
486+
*/
487+
if (isScrollProp(value)) {
488+
unpackedValues.push({ key, value: value.value })
489+
continue
490+
}
491+
396492
/**
397493
* Inform the client about the mergeable prop and use its
398494
* value
@@ -464,11 +560,18 @@ export async function buildStandardVisitProps(
464560
})
465561
)
466562

563+
for (const [key, value] of Object.entries(pageProps)) {
564+
if (isObject(value) && isScrollProp(value)) {
565+
scrollProps[key] = resolveScrollMetadata(newProps[key], value.pageName, value.wrapper)
566+
}
567+
}
568+
467569
return {
468570
props: newProps,
469571
mergeProps,
470572
deepMergeProps,
471573
deferredProps,
574+
scrollProps,
472575
}
473576
}
474577

@@ -503,6 +606,7 @@ export async function buildPartialRequestProps(
503606
) {
504607
const mergeProps: string[] = []
505608
const deepMergeProps: string[] = []
609+
const scrollProps: { [key: string]: ScrollMetadata } = {}
506610
const newProps: ComponentProps = {}
507611
const unpackedValues: Array<{
508612
key: string
@@ -543,6 +647,14 @@ export async function buildPartialRequestProps(
543647
continue
544648
}
545649

650+
/**
651+
* Unpack scroll prop value and track pageName for metadata derivation
652+
*/
653+
if (isScrollProp(value)) {
654+
unpackedValues.push({ key, value: value.value })
655+
continue
656+
}
657+
546658
/**
547659
* Inform the client about the mergeable prop
548660
*/
@@ -605,10 +717,17 @@ export async function buildPartialRequestProps(
605717
})
606718
)
607719

720+
for (const [key, value] of Object.entries(pageProps)) {
721+
if (isObject(value) && isScrollProp(value)) {
722+
scrollProps[key] = resolveScrollMetadata(newProps[key], value.pageName, value.wrapper)
723+
}
724+
}
725+
608726
return {
609727
props: newProps,
610728
mergeProps,
611729
deepMergeProps,
612730
deferredProps: {},
731+
scrollProps,
613732
}
614733
}

0 commit comments

Comments
 (0)