diff --git a/packages/core/src/page.ts b/packages/core/src/page.ts index de599bd2f..7f49457f4 100644 --- a/packages/core/src/page.ts +++ b/packages/core/src/page.ts @@ -1,6 +1,7 @@ import { eventHandler } from './eventHandler' import { fireNavigateEvent } from './events' import { history } from './history' +import { prefetchedRequests } from './prefetched' import { Scroll } from './scroll' import { Component, Page, PageEvent, PageHandler, PageResolver, RouterInitParams, Visit } from './types' import { hrefToUrl, isSameUrlWithoutHash } from './url' @@ -86,6 +87,10 @@ class CurrentPage { this.page = page this.cleared = false + if (this.hasOnceProps()) { + prefetchedRequests.updateCachedOncePropsFromCurrentPage() + } + if (isNewComponent) { this.fireEventsFor('newComponent') } @@ -157,6 +162,10 @@ class CurrentPage { return this.page } + public hasOnceProps(): boolean { + return Object.keys(this.page.onceProps ?? {}).length > 0 + } + public merge(data: Partial): void { this.page = { ...this.page, ...data } } @@ -216,6 +225,21 @@ class CurrentPage { public fireEventsFor(event: PageEvent): void { this.listeners.filter((listener) => listener.event === event).forEach((listener) => listener.callback()) } + + public mergeOncePropsIntoResponse(response: Page, { force = false }: { force?: boolean } = {}): void { + Object.entries(response.onceProps ?? {}).forEach(([key, onceProp]) => { + const existingOnceProp = this.page.onceProps?.[key] + + if (existingOnceProp === undefined) { + return + } + + if (force || response.props[onceProp.prop] === undefined) { + response.props[onceProp.prop] = this.page.props[existingOnceProp.prop] + response.onceProps![key].expiresAt = existingOnceProp.expiresAt + } + }) + } } export const page = new CurrentPage() diff --git a/packages/core/src/prefetched.ts b/packages/core/src/prefetched.ts index d37c83780..f99e18d85 100644 --- a/packages/core/src/prefetched.ts +++ b/packages/core/src/prefetched.ts @@ -1,5 +1,6 @@ import { cloneDeep } from 'lodash-es' import { objectsAreEqual } from './objectUtils' +import { page as currentPage } from './page' import { Response } from './response' import { timeToMs } from './time' import { @@ -7,6 +8,7 @@ import { CacheForOption, InFlightPrefetch, InternalActiveVisit, + Page, PrefetchedResponse, PrefetchOptions, PrefetchRemovalTimer, @@ -35,7 +37,7 @@ class PrefetchedRequests { return Promise.resolve() } - const [stale, expires] = this.extractStaleValues(cacheFor) + const [stale, prefetchExpiresIn] = this.extractStaleValues(cacheFor) const promise = new Promise((resolve, reject) => { sendFunc({ @@ -67,17 +69,26 @@ class PrefetchedRequests { }).then((response) => { this.remove(params) + const pageResponse = response.getPageResponse() + + currentPage.mergeOncePropsIntoResponse(pageResponse) + this.cached.push({ params: { ...params }, staleTimestamp: Date.now() + stale, + expiresAt: Date.now() + prefetchExpiresIn, response: promise, - singleUse: expires === 0, + singleUse: prefetchExpiresIn === 0, timestamp: Date.now(), inFlight: false, tags: Array.isArray(cacheTags) ? cacheTags : [cacheTags], }) - this.scheduleForRemoval(params, expires) + const oncePropExpiresIn = this.getShortestOncePropTtl(pageResponse) + this.scheduleForRemoval( + params, + oncePropExpiresIn ? Math.min(prefetchExpiresIn, oncePropExpiresIn) : prefetchExpiresIn, + ) this.removeFromInFlight(params) response.handlePrefetch() @@ -258,6 +269,43 @@ class PrefetchedRequests { ], ) } + + public updateCachedOncePropsFromCurrentPage(): void { + this.cached.forEach((prefetched) => { + prefetched.response.then((response) => { + const pageResponse = response.getPageResponse() + + currentPage.mergeOncePropsIntoResponse(pageResponse, { force: true }) + + const oncePropExpiresIn = this.getShortestOncePropTtl(pageResponse) + + if (oncePropExpiresIn === null) { + return + } + + const prefetchExpiresIn = prefetched.expiresAt - Date.now() + const expiresIn = Math.min(prefetchExpiresIn, oncePropExpiresIn) + + if (expiresIn > 0) { + this.scheduleForRemoval(prefetched.params, expiresIn) + } else { + this.remove(prefetched.params) + } + }) + }) + } + + protected getShortestOncePropTtl(page: Page): number | null { + const expiryTimestamps = Object.values(page.onceProps ?? {}) + .map((onceProp) => onceProp.expiresAt) + .filter((expiresAt): expiresAt is number => !!expiresAt) + + if (expiryTimestamps.length === 0) { + return null + } + + return Math.min(...expiryTimestamps) - Date.now() + } } export const prefetchedRequests = new PrefetchedRequests() diff --git a/packages/core/src/request.ts b/packages/core/src/request.ts index f062c7976..b8b65fd56 100644 --- a/packages/core/src/request.ts +++ b/packages/core/src/request.ts @@ -138,8 +138,18 @@ export class Request { 'X-Inertia': true, } - if (currentPage.get().version) { - headers['X-Inertia-Version'] = currentPage.get().version + const page = currentPage.get() + + if (page.version) { + headers['X-Inertia-Version'] = page.version + } + + const onceProps = Object.entries(page.onceProps || {}) + .filter(([, onceProp]) => !onceProp.expiresAt || onceProp.expiresAt > Date.now()) + .map(([key]) => key) + + if (onceProps.length > 0) { + headers['X-Inertia-Except-Once-Props'] = onceProps.join(',') } return headers diff --git a/packages/core/src/response.ts b/packages/core/src/response.ts index 3562f953e..345f72b5c 100644 --- a/packages/core/src/response.ts +++ b/packages/core/src/response.ts @@ -95,6 +95,10 @@ export class Response { this.requestParams.merge(params) } + public getPageResponse(): Page { + return (this.response.data = this.getDataFromResponse(this.response.data)) + } + protected async handleNonInertiaResponse() { if (this.isLocationVisit()) { const locationUrl = hrefToUrl(this.getHeader('x-inertia-location')) @@ -158,13 +162,14 @@ export class Response { } protected async setPage(): Promise { - const pageResponse = this.getDataFromResponse(this.response.data) + const pageResponse = this.getPageResponse() if (!this.shouldSetPage(pageResponse)) { return Promise.resolve() } this.mergeProps(pageResponse) + currentPage.mergeOncePropsIntoResponse(pageResponse) this.preserveEqualProps(pageResponse) await this.setRememberedState(pageResponse) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1ef98ca45..67f89c817 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -173,6 +173,13 @@ export interface Page { deepMergeProps?: string[] matchPropsOn?: string[] scrollProps?: Record + onceProps?: Record< + string, + { + prop: keyof PageProps + expiresAt?: number | null + } + > /** @internal */ rememberedState: Record @@ -564,6 +571,7 @@ export type PrefetchCancellationToken = { export type PrefetchedResponse = PrefetchObject & { staleTimestamp: number timestamp: number + expiresAt: number singleUse: boolean inFlight: false tags: string[] diff --git a/packages/react/test-app/Pages/OnceProps/CustomKeyPageA.tsx b/packages/react/test-app/Pages/OnceProps/CustomKeyPageA.tsx new file mode 100644 index 000000000..16e033dd1 --- /dev/null +++ b/packages/react/test-app/Pages/OnceProps/CustomKeyPageA.tsx @@ -0,0 +1,11 @@ +import { Link } from '@inertiajs/react' + +export default ({ userPermissions, bar }: { userPermissions: string; bar: string }) => { + return ( + <> +

Permissions: {userPermissions}

+

Bar: {bar}

+ Go to Custom Key Page B + + ) +} diff --git a/packages/react/test-app/Pages/OnceProps/CustomKeyPageB.tsx b/packages/react/test-app/Pages/OnceProps/CustomKeyPageB.tsx new file mode 100644 index 000000000..92c94c205 --- /dev/null +++ b/packages/react/test-app/Pages/OnceProps/CustomKeyPageB.tsx @@ -0,0 +1,11 @@ +import { Link } from '@inertiajs/react' + +export default ({ permissions, bar }: { permissions: string; bar: string }) => { + return ( + <> +

Permissions: {permissions}

+

Bar: {bar}

+ Go to Custom Key Page A + + ) +} diff --git a/packages/react/test-app/Pages/OnceProps/DeferredPageA.tsx b/packages/react/test-app/Pages/OnceProps/DeferredPageA.tsx new file mode 100644 index 000000000..727b225f1 --- /dev/null +++ b/packages/react/test-app/Pages/OnceProps/DeferredPageA.tsx @@ -0,0 +1,26 @@ +import { Deferred, Link, usePage } from '@inertiajs/react' + +const Foo = () => { + const { foo } = usePage<{ foo?: { text: string } }>().props + + return <>{foo?.text} +} + +export default ({ bar }: { bar: string }) => { + return ( + <> + Loading foo...}> +

+ Foo: +

+
+ +

Bar: {bar}

+ + Go to Deferred Page B + + Go to Deferred Page C + + + ) +} diff --git a/packages/react/test-app/Pages/OnceProps/DeferredPageB.tsx b/packages/react/test-app/Pages/OnceProps/DeferredPageB.tsx new file mode 100644 index 000000000..d941c6fd9 --- /dev/null +++ b/packages/react/test-app/Pages/OnceProps/DeferredPageB.tsx @@ -0,0 +1,23 @@ +import { Deferred, Link, usePage } from '@inertiajs/react' + +const Foo = () => { + const { foo } = usePage<{ foo?: { text: string } }>().props + + return <>{foo?.text} +} + +export default ({ bar }: { bar: string }) => { + return ( + <> + Loading foo...}> +

+ Foo: +

+
+ +

Bar: {bar}

+ + Go to Deferred Page A + + ) +} diff --git a/packages/react/test-app/Pages/OnceProps/DeferredPageC.tsx b/packages/react/test-app/Pages/OnceProps/DeferredPageC.tsx new file mode 100644 index 000000000..d941c6fd9 --- /dev/null +++ b/packages/react/test-app/Pages/OnceProps/DeferredPageC.tsx @@ -0,0 +1,23 @@ +import { Deferred, Link, usePage } from '@inertiajs/react' + +const Foo = () => { + const { foo } = usePage<{ foo?: { text: string } }>().props + + return <>{foo?.text} +} + +export default ({ bar }: { bar: string }) => { + return ( + <> + Loading foo...}> +

+ Foo: +

+
+ +

Bar: {bar}

+ + Go to Deferred Page A + + ) +} diff --git a/packages/react/test-app/Pages/OnceProps/MergePageA.tsx b/packages/react/test-app/Pages/OnceProps/MergePageA.tsx new file mode 100644 index 000000000..1d0b5f38d --- /dev/null +++ b/packages/react/test-app/Pages/OnceProps/MergePageA.tsx @@ -0,0 +1,12 @@ +import { Link, router } from '@inertiajs/react' + +export default ({ items, bar }: { items: string[]; bar: string }) => { + return ( + <> +

Items count: {items.length}

+

Bar: {bar}

+ Go to Merge Page B + + + ) +} diff --git a/packages/react/test-app/Pages/OnceProps/MergePageB.tsx b/packages/react/test-app/Pages/OnceProps/MergePageB.tsx new file mode 100644 index 000000000..5895ab062 --- /dev/null +++ b/packages/react/test-app/Pages/OnceProps/MergePageB.tsx @@ -0,0 +1,12 @@ +import { Link, router } from '@inertiajs/react' + +export default ({ items, bar }: { items: string[]; bar: string }) => { + return ( + <> +

Items count: {items.length}

+

Bar: {bar}

+ Go to Merge Page A + + + ) +} diff --git a/packages/react/test-app/Pages/OnceProps/OptionalPageA.tsx b/packages/react/test-app/Pages/OnceProps/OptionalPageA.tsx new file mode 100644 index 000000000..acfd2fb40 --- /dev/null +++ b/packages/react/test-app/Pages/OnceProps/OptionalPageA.tsx @@ -0,0 +1,12 @@ +import { Link, router } from '@inertiajs/react' + +export default ({ foo, bar }: { foo?: string; bar: string }) => { + return ( + <> +

Foo: {foo ?? 'not loaded'}

+

Bar: {bar}

+ Go to Optional Page B + + + ) +} diff --git a/packages/react/test-app/Pages/OnceProps/OptionalPageB.tsx b/packages/react/test-app/Pages/OnceProps/OptionalPageB.tsx new file mode 100644 index 000000000..fd7e24f4d --- /dev/null +++ b/packages/react/test-app/Pages/OnceProps/OptionalPageB.tsx @@ -0,0 +1,12 @@ +import { Link, router } from '@inertiajs/react' + +export default ({ foo, bar }: { foo?: string; bar: string }) => { + return ( + <> +

Foo: {foo ?? 'not loaded'}

+

Bar: {bar}

+ Go to Optional Page A + + + ) +} diff --git a/packages/react/test-app/Pages/OnceProps/PageA.tsx b/packages/react/test-app/Pages/OnceProps/PageA.tsx new file mode 100644 index 000000000..a903331a9 --- /dev/null +++ b/packages/react/test-app/Pages/OnceProps/PageA.tsx @@ -0,0 +1,20 @@ +import { Link, router } from '@inertiajs/react' + +export default ({ foo, bar }: { foo: string; bar: string }) => { + return ( + <> +

Foo: {foo}

+

Bar: {bar}

+ Go to Page B + Go to Page C + + Go to Page D + + + Go to Page E (short cache) + + + + + ) +} diff --git a/packages/react/test-app/Pages/OnceProps/PageB.tsx b/packages/react/test-app/Pages/OnceProps/PageB.tsx new file mode 100644 index 000000000..eff0963c8 --- /dev/null +++ b/packages/react/test-app/Pages/OnceProps/PageB.tsx @@ -0,0 +1,12 @@ +import { Link, router } from '@inertiajs/react' + +export default ({ foo, bar }: { foo: string; bar: string }) => { + return ( + <> +

Foo: {foo}

+

Bar: {bar}

+ Go to Page A + + + ) +} diff --git a/packages/react/test-app/Pages/OnceProps/PageC.tsx b/packages/react/test-app/Pages/OnceProps/PageC.tsx new file mode 100644 index 000000000..a02e99272 --- /dev/null +++ b/packages/react/test-app/Pages/OnceProps/PageC.tsx @@ -0,0 +1,13 @@ +import { Link } from '@inertiajs/react' + +export default () => { + return ( + <> + Go to Page A + Go to Page B + + Go to Page D + + + ) +} diff --git a/packages/react/test-app/Pages/OnceProps/PageD.tsx b/packages/react/test-app/Pages/OnceProps/PageD.tsx new file mode 100644 index 000000000..0e20e29d9 --- /dev/null +++ b/packages/react/test-app/Pages/OnceProps/PageD.tsx @@ -0,0 +1,8 @@ +export default ({ foo, bar }: { foo: string; bar: string }) => { + return ( + <> +

Foo: {foo}

+

Bar: {bar}

+ + ) +} diff --git a/packages/react/test-app/Pages/OnceProps/PageE.tsx b/packages/react/test-app/Pages/OnceProps/PageE.tsx new file mode 100644 index 000000000..0e20e29d9 --- /dev/null +++ b/packages/react/test-app/Pages/OnceProps/PageE.tsx @@ -0,0 +1,8 @@ +export default ({ foo, bar }: { foo: string; bar: string }) => { + return ( + <> +

Foo: {foo}

+

Bar: {bar}

+ + ) +} diff --git a/packages/react/test-app/Pages/OnceProps/TtlPageA.tsx b/packages/react/test-app/Pages/OnceProps/TtlPageA.tsx new file mode 100644 index 000000000..11e1a4e53 --- /dev/null +++ b/packages/react/test-app/Pages/OnceProps/TtlPageA.tsx @@ -0,0 +1,15 @@ +import { Link, router } from '@inertiajs/react' + +export default ({ foo, bar }: { foo: string; bar: string }) => { + return ( + <> +

Foo: {foo}

+

Bar: {bar}

+ Go to TTL Page B + + Go to TTL Page C + + + + ) +} diff --git a/packages/react/test-app/Pages/OnceProps/TtlPageB.tsx b/packages/react/test-app/Pages/OnceProps/TtlPageB.tsx new file mode 100644 index 000000000..070258ada --- /dev/null +++ b/packages/react/test-app/Pages/OnceProps/TtlPageB.tsx @@ -0,0 +1,11 @@ +import { Link } from '@inertiajs/react' + +export default ({ foo, bar }: { foo: string; bar: string }) => { + return ( + <> +

Foo: {foo}

+

Bar: {bar}

+ Go to TTL Page A + + ) +} diff --git a/packages/react/test-app/Pages/OnceProps/TtlPageC.tsx b/packages/react/test-app/Pages/OnceProps/TtlPageC.tsx new file mode 100644 index 000000000..070258ada --- /dev/null +++ b/packages/react/test-app/Pages/OnceProps/TtlPageC.tsx @@ -0,0 +1,11 @@ +import { Link } from '@inertiajs/react' + +export default ({ foo, bar }: { foo: string; bar: string }) => { + return ( + <> +

Foo: {foo}

+

Bar: {bar}

+ Go to TTL Page A + + ) +} diff --git a/packages/svelte/test-app/Pages/OnceProps/CustomKeyPageA.svelte b/packages/svelte/test-app/Pages/OnceProps/CustomKeyPageA.svelte new file mode 100644 index 000000000..045596c62 --- /dev/null +++ b/packages/svelte/test-app/Pages/OnceProps/CustomKeyPageA.svelte @@ -0,0 +1,10 @@ + + +

Permissions: {userPermissions}

+

Bar: {bar}

+Go to Custom Key Page B diff --git a/packages/svelte/test-app/Pages/OnceProps/CustomKeyPageB.svelte b/packages/svelte/test-app/Pages/OnceProps/CustomKeyPageB.svelte new file mode 100644 index 000000000..eed728b7b --- /dev/null +++ b/packages/svelte/test-app/Pages/OnceProps/CustomKeyPageB.svelte @@ -0,0 +1,10 @@ + + +

Permissions: {permissions}

+

Bar: {bar}

+Go to Custom Key Page A diff --git a/packages/svelte/test-app/Pages/OnceProps/DeferredPageA.svelte b/packages/svelte/test-app/Pages/OnceProps/DeferredPageA.svelte new file mode 100644 index 000000000..2d8bccc17 --- /dev/null +++ b/packages/svelte/test-app/Pages/OnceProps/DeferredPageA.svelte @@ -0,0 +1,19 @@ + + + + +
Loading foo...
+
+ +

Foo: {foo?.text}

+
+ +

Bar: {bar}

+ +Go to Deferred Page B +Go to Deferred Page C diff --git a/packages/svelte/test-app/Pages/OnceProps/DeferredPageB.svelte b/packages/svelte/test-app/Pages/OnceProps/DeferredPageB.svelte new file mode 100644 index 000000000..93c76c720 --- /dev/null +++ b/packages/svelte/test-app/Pages/OnceProps/DeferredPageB.svelte @@ -0,0 +1,18 @@ + + + + +
Loading foo...
+
+ +

Foo: {foo?.text}

+
+ +

Bar: {bar}

+ +Go to Deferred Page A diff --git a/packages/svelte/test-app/Pages/OnceProps/DeferredPageC.svelte b/packages/svelte/test-app/Pages/OnceProps/DeferredPageC.svelte new file mode 100644 index 000000000..93c76c720 --- /dev/null +++ b/packages/svelte/test-app/Pages/OnceProps/DeferredPageC.svelte @@ -0,0 +1,18 @@ + + + + +
Loading foo...
+
+ +

Foo: {foo?.text}

+
+ +

Bar: {bar}

+ +Go to Deferred Page A diff --git a/packages/svelte/test-app/Pages/OnceProps/MergePageA.svelte b/packages/svelte/test-app/Pages/OnceProps/MergePageA.svelte new file mode 100644 index 000000000..c2141f9c9 --- /dev/null +++ b/packages/svelte/test-app/Pages/OnceProps/MergePageA.svelte @@ -0,0 +1,11 @@ + + +

Items count: {items.length}

+

Bar: {bar}

+Go to Merge Page B + diff --git a/packages/svelte/test-app/Pages/OnceProps/MergePageB.svelte b/packages/svelte/test-app/Pages/OnceProps/MergePageB.svelte new file mode 100644 index 000000000..d0d9f62ce --- /dev/null +++ b/packages/svelte/test-app/Pages/OnceProps/MergePageB.svelte @@ -0,0 +1,11 @@ + + +

Items count: {items.length}

+

Bar: {bar}

+Go to Merge Page A + diff --git a/packages/svelte/test-app/Pages/OnceProps/OptionalPageA.svelte b/packages/svelte/test-app/Pages/OnceProps/OptionalPageA.svelte new file mode 100644 index 000000000..cb41e7b73 --- /dev/null +++ b/packages/svelte/test-app/Pages/OnceProps/OptionalPageA.svelte @@ -0,0 +1,11 @@ + + +

Foo: {foo ?? 'not loaded'}

+

Bar: {bar}

+Go to Optional Page B + diff --git a/packages/svelte/test-app/Pages/OnceProps/OptionalPageB.svelte b/packages/svelte/test-app/Pages/OnceProps/OptionalPageB.svelte new file mode 100644 index 000000000..4e4ea88ef --- /dev/null +++ b/packages/svelte/test-app/Pages/OnceProps/OptionalPageB.svelte @@ -0,0 +1,11 @@ + + +

Foo: {foo ?? 'not loaded'}

+

Bar: {bar}

+Go to Optional Page A + diff --git a/packages/svelte/test-app/Pages/OnceProps/PageA.svelte b/packages/svelte/test-app/Pages/OnceProps/PageA.svelte new file mode 100644 index 000000000..5c8aeb987 --- /dev/null +++ b/packages/svelte/test-app/Pages/OnceProps/PageA.svelte @@ -0,0 +1,15 @@ + + +

Foo: {foo}

+

Bar: {bar}

+Go to Page B +Go to Page C +Go to Page D +Go to Page E (short cache) + + diff --git a/packages/svelte/test-app/Pages/OnceProps/PageB.svelte b/packages/svelte/test-app/Pages/OnceProps/PageB.svelte new file mode 100644 index 000000000..7aa1eba84 --- /dev/null +++ b/packages/svelte/test-app/Pages/OnceProps/PageB.svelte @@ -0,0 +1,11 @@ + + +

Foo: {foo}

+

Bar: {bar}

+Go to Page A + diff --git a/packages/svelte/test-app/Pages/OnceProps/PageC.svelte b/packages/svelte/test-app/Pages/OnceProps/PageC.svelte new file mode 100644 index 000000000..b9b6e394f --- /dev/null +++ b/packages/svelte/test-app/Pages/OnceProps/PageC.svelte @@ -0,0 +1,7 @@ + + +Go to Page A +Go to Page B +Go to Page D diff --git a/packages/svelte/test-app/Pages/OnceProps/PageD.svelte b/packages/svelte/test-app/Pages/OnceProps/PageD.svelte new file mode 100644 index 000000000..4811336b4 --- /dev/null +++ b/packages/svelte/test-app/Pages/OnceProps/PageD.svelte @@ -0,0 +1,7 @@ + + +

Foo: {foo}

+

Bar: {bar}

diff --git a/packages/svelte/test-app/Pages/OnceProps/PageE.svelte b/packages/svelte/test-app/Pages/OnceProps/PageE.svelte new file mode 100644 index 000000000..4811336b4 --- /dev/null +++ b/packages/svelte/test-app/Pages/OnceProps/PageE.svelte @@ -0,0 +1,7 @@ + + +

Foo: {foo}

+

Bar: {bar}

diff --git a/packages/svelte/test-app/Pages/OnceProps/TtlPageA.svelte b/packages/svelte/test-app/Pages/OnceProps/TtlPageA.svelte new file mode 100644 index 000000000..e23f2a1f9 --- /dev/null +++ b/packages/svelte/test-app/Pages/OnceProps/TtlPageA.svelte @@ -0,0 +1,12 @@ + + +

Foo: {foo}

+

Bar: {bar}

+Go to TTL Page B +Go to TTL Page C + diff --git a/packages/svelte/test-app/Pages/OnceProps/TtlPageB.svelte b/packages/svelte/test-app/Pages/OnceProps/TtlPageB.svelte new file mode 100644 index 000000000..6774ce6b8 --- /dev/null +++ b/packages/svelte/test-app/Pages/OnceProps/TtlPageB.svelte @@ -0,0 +1,10 @@ + + +

Foo: {foo}

+

Bar: {bar}

+Go to TTL Page A diff --git a/packages/svelte/test-app/Pages/OnceProps/TtlPageC.svelte b/packages/svelte/test-app/Pages/OnceProps/TtlPageC.svelte new file mode 100644 index 000000000..6774ce6b8 --- /dev/null +++ b/packages/svelte/test-app/Pages/OnceProps/TtlPageC.svelte @@ -0,0 +1,10 @@ + + +

Foo: {foo}

+

Bar: {bar}

+Go to TTL Page A diff --git a/packages/vue3/test-app/Pages/OnceProps/CustomKeyPageA.vue b/packages/vue3/test-app/Pages/OnceProps/CustomKeyPageA.vue new file mode 100644 index 000000000..5209d79b1 --- /dev/null +++ b/packages/vue3/test-app/Pages/OnceProps/CustomKeyPageA.vue @@ -0,0 +1,11 @@ + + + diff --git a/packages/vue3/test-app/Pages/OnceProps/CustomKeyPageB.vue b/packages/vue3/test-app/Pages/OnceProps/CustomKeyPageB.vue new file mode 100644 index 000000000..daacbd17e --- /dev/null +++ b/packages/vue3/test-app/Pages/OnceProps/CustomKeyPageB.vue @@ -0,0 +1,11 @@ + + + diff --git a/packages/vue3/test-app/Pages/OnceProps/DeferredPageA.vue b/packages/vue3/test-app/Pages/OnceProps/DeferredPageA.vue new file mode 100644 index 000000000..0116cb10e --- /dev/null +++ b/packages/vue3/test-app/Pages/OnceProps/DeferredPageA.vue @@ -0,0 +1,23 @@ + + + diff --git a/packages/vue3/test-app/Pages/OnceProps/DeferredPageB.vue b/packages/vue3/test-app/Pages/OnceProps/DeferredPageB.vue new file mode 100644 index 000000000..5b872324a --- /dev/null +++ b/packages/vue3/test-app/Pages/OnceProps/DeferredPageB.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/vue3/test-app/Pages/OnceProps/DeferredPageC.vue b/packages/vue3/test-app/Pages/OnceProps/DeferredPageC.vue new file mode 100644 index 000000000..5b872324a --- /dev/null +++ b/packages/vue3/test-app/Pages/OnceProps/DeferredPageC.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/vue3/test-app/Pages/OnceProps/MergePageA.vue b/packages/vue3/test-app/Pages/OnceProps/MergePageA.vue new file mode 100644 index 000000000..456d85d3b --- /dev/null +++ b/packages/vue3/test-app/Pages/OnceProps/MergePageA.vue @@ -0,0 +1,12 @@ + + + diff --git a/packages/vue3/test-app/Pages/OnceProps/MergePageB.vue b/packages/vue3/test-app/Pages/OnceProps/MergePageB.vue new file mode 100644 index 000000000..5450f8424 --- /dev/null +++ b/packages/vue3/test-app/Pages/OnceProps/MergePageB.vue @@ -0,0 +1,12 @@ + + + diff --git a/packages/vue3/test-app/Pages/OnceProps/OptionalPageA.vue b/packages/vue3/test-app/Pages/OnceProps/OptionalPageA.vue new file mode 100644 index 000000000..854de286a --- /dev/null +++ b/packages/vue3/test-app/Pages/OnceProps/OptionalPageA.vue @@ -0,0 +1,12 @@ + + + diff --git a/packages/vue3/test-app/Pages/OnceProps/OptionalPageB.vue b/packages/vue3/test-app/Pages/OnceProps/OptionalPageB.vue new file mode 100644 index 000000000..b983964f7 --- /dev/null +++ b/packages/vue3/test-app/Pages/OnceProps/OptionalPageB.vue @@ -0,0 +1,12 @@ + + + diff --git a/packages/vue3/test-app/Pages/OnceProps/PageA.vue b/packages/vue3/test-app/Pages/OnceProps/PageA.vue new file mode 100644 index 000000000..087d78cd7 --- /dev/null +++ b/packages/vue3/test-app/Pages/OnceProps/PageA.vue @@ -0,0 +1,16 @@ + + + diff --git a/packages/vue3/test-app/Pages/OnceProps/PageB.vue b/packages/vue3/test-app/Pages/OnceProps/PageB.vue new file mode 100644 index 000000000..9e8dc01a9 --- /dev/null +++ b/packages/vue3/test-app/Pages/OnceProps/PageB.vue @@ -0,0 +1,12 @@ + + + diff --git a/packages/vue3/test-app/Pages/OnceProps/PageC.vue b/packages/vue3/test-app/Pages/OnceProps/PageC.vue new file mode 100644 index 000000000..2d4cc8a92 --- /dev/null +++ b/packages/vue3/test-app/Pages/OnceProps/PageC.vue @@ -0,0 +1,9 @@ + + + diff --git a/packages/vue3/test-app/Pages/OnceProps/PageD.vue b/packages/vue3/test-app/Pages/OnceProps/PageD.vue new file mode 100644 index 000000000..8e9a9b6b4 --- /dev/null +++ b/packages/vue3/test-app/Pages/OnceProps/PageD.vue @@ -0,0 +1,8 @@ + + + diff --git a/packages/vue3/test-app/Pages/OnceProps/PageE.vue b/packages/vue3/test-app/Pages/OnceProps/PageE.vue new file mode 100644 index 000000000..8e9a9b6b4 --- /dev/null +++ b/packages/vue3/test-app/Pages/OnceProps/PageE.vue @@ -0,0 +1,8 @@ + + + diff --git a/packages/vue3/test-app/Pages/OnceProps/TtlPageA.vue b/packages/vue3/test-app/Pages/OnceProps/TtlPageA.vue new file mode 100644 index 000000000..f74ce16e1 --- /dev/null +++ b/packages/vue3/test-app/Pages/OnceProps/TtlPageA.vue @@ -0,0 +1,13 @@ + + + diff --git a/packages/vue3/test-app/Pages/OnceProps/TtlPageB.vue b/packages/vue3/test-app/Pages/OnceProps/TtlPageB.vue new file mode 100644 index 000000000..85e539028 --- /dev/null +++ b/packages/vue3/test-app/Pages/OnceProps/TtlPageB.vue @@ -0,0 +1,11 @@ + + + diff --git a/packages/vue3/test-app/Pages/OnceProps/TtlPageC.vue b/packages/vue3/test-app/Pages/OnceProps/TtlPageC.vue new file mode 100644 index 000000000..85e539028 --- /dev/null +++ b/packages/vue3/test-app/Pages/OnceProps/TtlPageC.vue @@ -0,0 +1,11 @@ + + + diff --git a/tests/app/server.js b/tests/app/server.js index a5371ae34..43e5fe10b 100644 --- a/tests/app/server.js +++ b/tests/app/server.js @@ -1421,6 +1421,168 @@ app.post('/view-transition/form-errors', (req, res) => }), ) +const getOncePropsData = (req, prop = 'foo') => { + const isInertiaRequest = !!req.headers['x-inertia'] + const partialData = req.headers['x-inertia-partial-data']?.split(',') ?? [] + const loadedOnceProps = req.headers['x-inertia-except-once-props']?.split(',') ?? [] + const isPartialRequest = partialData.includes(prop) + const hasPropAlready = loadedOnceProps.includes(prop) + const shouldResolveProp = !isInertiaRequest || isPartialRequest || !hasPropAlready + + return { + isInertiaRequest, + partialData, + loadedOnceProps, + isPartialRequest, + hasPropAlready, + shouldResolveProp, + } +} + +app.get('/once-props/page-a', (req, res) => { + const { shouldResolveProp } = getOncePropsData(req) + + inertia.render(req, res, { + component: 'OnceProps/PageA', + props: { + foo: shouldResolveProp ? 'foo-a-' + Date.now() : undefined, + bar: 'bar-a', + }, + onceProps: { foo: { prop: 'foo', expiresAt: null } }, + }) +}) + +app.get('/once-props/page-b', (req, res) => { + const { shouldResolveProp } = getOncePropsData(req) + + inertia.render(req, res, { + component: 'OnceProps/PageB', + props: { + foo: shouldResolveProp ? 'foo-b-' + Date.now() : undefined, + bar: 'bar-b', + }, + onceProps: { foo: { prop: 'foo', expiresAt: null } }, + }) +}) + +app.get('/once-props/page-c', (req, res) => { + inertia.render(req, res, { + component: 'OnceProps/PageC', + }) +}) + +app.get('/once-props/page-d', (req, res) => { + const { shouldResolveProp } = getOncePropsData(req) + + inertia.render(req, res, { + component: 'OnceProps/PageD', + props: { + foo: shouldResolveProp ? 'foo-d-' + Date.now() : undefined, + bar: 'bar-d', + }, + onceProps: { foo: { prop: 'foo', expiresAt: null } }, + }) +}) + +app.get('/once-props/page-e', (req, res) => { + const { shouldResolveProp } = getOncePropsData(req) + + inertia.render(req, res, { + component: 'OnceProps/PageE', + props: { + foo: shouldResolveProp ? 'foo-e-' + Date.now() : undefined, + bar: 'bar-e', + }, + onceProps: { foo: { prop: 'foo', expiresAt: null } }, + }) +}) + +app.get('/once-props/deferred/:page', (req, res) => { + const { isPartialRequest, hasPropAlready } = getOncePropsData(req) + const page = req.params.page + + if (isPartialRequest) { + return setTimeout(() => { + inertia.render(req, res, { + component: `OnceProps/DeferredPage${page.toUpperCase()}`, + props: { + foo: { text: `foo-${page}-` + Date.now() }, + bar: `bar-${page}`, + }, + onceProps: { foo: { prop: 'foo', expiresAt: null } }, + }) + }, 100) + } + + inertia.render(req, res, { + component: `OnceProps/DeferredPage${page.toUpperCase()}`, + props: { + bar: `bar-${page}`, + }, + deferredProps: hasPropAlready ? {} : { default: ['foo'] }, + onceProps: { foo: { prop: 'foo', expiresAt: null } }, + }) +}) + +app.get('/once-props/ttl/:page', (req, res) => { + const { shouldResolveProp } = getOncePropsData(req) + const page = req.params.page + const expiresAt = Date.now() + 2000 + + inertia.render(req, res, { + component: `OnceProps/TtlPage${page.toUpperCase()}`, + props: { + foo: shouldResolveProp ? `foo-${page}-` + Date.now() : undefined, + bar: `bar-${page}`, + }, + onceProps: { foo: { prop: 'foo', expiresAt } }, + }) +}) + +app.get('/once-props/optional/:page', (req, res) => { + const { isPartialRequest, hasPropAlready } = getOncePropsData(req) + const page = req.params.page + + inertia.render(req, res, { + component: `OnceProps/OptionalPage${page.toUpperCase()}`, + props: { + foo: isPartialRequest ? `foo-${page}-` + Date.now() : undefined, + bar: `bar-${page}`, + }, + onceProps: isPartialRequest || hasPropAlready ? { foo: { prop: 'foo', expiresAt: null } } : {}, + }) +}) + +app.get('/once-props/merge/:page', (req, res) => { + const { shouldResolveProp } = getOncePropsData(req, 'items') + const page = req.params.page + + inertia.render(req, res, { + component: `OnceProps/MergePage${page.toUpperCase()}`, + props: { + items: shouldResolveProp ? new Array(3).fill(page) : undefined, + bar: `bar-${page}`, + }, + mergeProps: ['items'], + onceProps: { items: { prop: 'items', expiresAt: null } }, + }) +}) + +app.get('/once-props/custom-key/:page', (req, res) => { + const page = req.params.page + const propName = page === 'a' ? 'userPermissions' : 'permissions' + const { shouldResolveProp } = getOncePropsData(req, 'user-permissions') + + inertia.render(req, res, { + component: `OnceProps/CustomKeyPage${page.toUpperCase()}`, + props: { + [propName]: shouldResolveProp ? `perms-${page}-` + Date.now() : undefined, + bar: `bar-${page}`, + }, + onceProps: { 'user-permissions': { prop: propName, expiresAt: null } }, + }) +}) + app.all('*page', (req, res) => inertia.render(req, res)) // Send errors to the console (instead of crashing the server) diff --git a/tests/once-props.spec.ts b/tests/once-props.spec.ts new file mode 100644 index 000000000..3de695c68 --- /dev/null +++ b/tests/once-props.spec.ts @@ -0,0 +1,442 @@ +import { expect, test } from '@playwright/test' +import { clickAndWaitForResponse } from './support' + +test.beforeEach(async ({ page }) => { + await page.goto('/once-props/page-a') +}) + +test('loads the prop on initial page load and then preserves it after navigation', async ({ page }) => { + await expect(page.getByText('Foo: foo-a')).toBeVisible() + await expect(page.getByText('Bar: bar-a')).toBeVisible() + + await page.getByRole('link', { name: 'Go to Page B' }).click() + + await expect(page).toHaveURL('/once-props/page-b') + await expect(page.getByText('Bar: bar-b')).toBeVisible() + await expect(page.getByText('Foo: foo-a')).toBeVisible() + + await page.reload() + + await expect(page).toHaveURL('/once-props/page-b') + await expect(page.getByText('Foo: foo-b')).toBeVisible() + await expect(page.getByText('Bar: bar-b')).toBeVisible() +}) + +test('navigating back and forward preserves the prop', async ({ page }) => { + await expect(page.getByText('Foo: foo-a')).toBeVisible() + await expect(page.getByText('Bar: bar-a')).toBeVisible() + + await page.getByRole('link', { name: 'Go to Page B' }).click() + + await expect(page).toHaveURL('/once-props/page-b') + await expect(page.getByText('Bar: bar-b')).toBeVisible() + await expect(page.getByText('Foo: foo-a')).toBeVisible() + + await page.goBack() + + await expect(page).toHaveURL('/once-props/page-a') + await expect(page.getByText('Foo: foo-a')).toBeVisible() + await expect(page.getByText('Bar: bar-a')).toBeVisible() + + await page.goForward() + + await expect(page).toHaveURL('/once-props/page-b') + await expect(page.getByText('Bar: bar-b')).toBeVisible() + await expect(page.getByText('Foo: foo-a')).toBeVisible() +}) + +test('partial reload preserves the prop', async ({ page }) => { + const fooText = await page.locator('#foo').innerText() + + await page.getByRole('button', { name: 'Reload (only foo)' }).click() + await expect(page.getByText(fooText)).not.toBeVisible() + + const newFooText = await page.locator('#foo').innerText() + + expect(newFooText.startsWith('Foo: foo-a')).toBe(true) + expect(newFooText).not.toBe(fooText) + + await page.getByRole('link', { name: 'Go to Page B' }).click() + + await expect(page).toHaveURL('/once-props/page-b') + await expect(page.getByText(newFooText)).toBeVisible() +}) + +test('navigating through an intermediary page without the once prop', async ({ page }) => { + await expect(page.getByText('Foo: foo-a')).toBeVisible() + await expect(page.getByText('Bar: bar-a')).toBeVisible() + + await page.getByRole('link', { name: 'Go to Page C' }).click() + + await expect(page).toHaveURL('/once-props/page-c') + + await page.getByRole('link', { name: 'Go to Page B' }).click() + + await expect(page).toHaveURL('/once-props/page-b') + await expect(page.getByText('Bar: bar-b')).toBeVisible() + await expect(page.getByText('Foo: foo-b')).toBeVisible() +}) + +test('deferred once prop is loaded via defer and then remembered on navigation', async ({ page }) => { + await page.goto('/once-props/deferred/a') + + await expect(page.getByText('Loading foo...')).toBeVisible() + await expect(page.getByText('Bar: bar-a')).toBeVisible() + + await page.waitForResponse( + (response) => + response.url().includes('/once-props/deferred/a') && + response.request().headers()['x-inertia-partial-data'] === 'foo', + ) + + await expect(page.getByText('Loading foo...')).not.toBeVisible() + await expect(page.getByText('Foo: foo-a')).toBeVisible() + + const fooText = await page.locator('#foo').innerText() + + await clickAndWaitForResponse(page, 'Go to Deferred Page B', '/once-props/deferred/b') + + await expect(page).toHaveURL('/once-props/deferred/b') + await expect(page.getByText('Bar: bar-b')).toBeVisible() + await expect(page.getByText('Loading foo...')).not.toBeVisible() + await expect(page.getByText(fooText)).toBeVisible() +}) + +test('once prop with TTL is remembered within TTL window and reloaded after expiry', async ({ page }) => { + await page.goto('/once-props/ttl/a') + + await expect(page.getByText('Foo: foo-a')).toBeVisible() + await expect(page.getByText('Bar: bar-a')).toBeVisible() + + const initialFooText = await page.locator('#foo').innerText() + + await page.getByRole('link', { name: 'Go to TTL Page B' }).click() + + await expect(page).toHaveURL('/once-props/ttl/b') + await expect(page.getByText('Bar: bar-b')).toBeVisible() + await expect(page.getByText(initialFooText)).toBeVisible() + + await page.getByRole('link', { name: 'Go to TTL Page A' }).click() + + await expect(page).toHaveURL('/once-props/ttl/a') + await expect(page.getByText(initialFooText)).toBeVisible() + + await page.waitForTimeout(2500) + + await page.getByRole('link', { name: 'Go to TTL Page B' }).click() + + await expect(page).toHaveURL('/once-props/ttl/b') + await expect(page.getByText('Bar: bar-b')).toBeVisible() + + const newFooText = await page.locator('#foo').innerText() + expect(newFooText).not.toBe(initialFooText) + expect(newFooText.startsWith('Foo: foo-b')).toBe(true) +}) + +test('once prop with TTL has its expiry reset after reload', async ({ page }) => { + await page.goto('/once-props/ttl/a') + + await expect(page.getByText('Foo: foo-a')).toBeVisible() + const initialFooText = await page.locator('#foo').innerText() + + await page.waitForTimeout(2500) + + await page.getByRole('button', { name: 'Reload foo' }).click() + await expect(page.getByText(initialFooText)).not.toBeVisible() + + const reloadedFooText = await page.locator('#foo').innerText() + expect(reloadedFooText).not.toBe(initialFooText) + expect(reloadedFooText.startsWith('Foo: foo-a')).toBe(true) + + await page.getByRole('link', { name: 'Go to TTL Page B' }).click() + + await expect(page).toHaveURL('/once-props/ttl/b') + await expect(page.getByText('Bar: bar-b')).toBeVisible() + await expect(page.getByText(reloadedFooText)).toBeVisible() +}) + +test('once prop TTL is preserved across navigation and expires correctly', async ({ page }) => { + await page.goto('/once-props/ttl/a') + + await expect(page.getByText('Foo: foo-a')).toBeVisible() + const initialFooText = await page.locator('#foo').innerText() + + await page.waitForTimeout(1000) + + await page.getByRole('link', { name: 'Go to TTL Page B' }).click() + await expect(page).toHaveURL('/once-props/ttl/b') + await expect(page.getByText(initialFooText)).toBeVisible() + + await page.waitForTimeout(1500) + + await page.getByRole('link', { name: 'Go to TTL Page A' }).click() + await expect(page).toHaveURL('/once-props/ttl/a') + + const newFooText = await page.locator('#foo').innerText() + expect(newFooText).not.toBe(initialFooText) + expect(newFooText.startsWith('Foo: foo-a')).toBe(true) +}) + +test('optional once prop is not loaded initially, fetched via reload, then cached on navigation', async ({ page }) => { + await page.goto('/once-props/optional/a') + + await expect(page.getByText('Foo: not loaded')).toBeVisible() + await expect(page.getByText('Bar: bar-a')).toBeVisible() + + await clickAndWaitForResponse(page, 'Load foo', '/once-props/optional/a', 'button') + + await expect(page.getByText('Foo: foo-a')).toBeVisible() + + const fooText = await page.locator('#foo').innerText() + expect(fooText.startsWith('Foo: foo-a')).toBe(true) + + await clickAndWaitForResponse(page, 'Go to Optional Page B', '/once-props/optional/b') + + await expect(page).toHaveURL('/once-props/optional/b') + await expect(page.getByText('Bar: bar-b')).toBeVisible() + await expect(page.getByText(fooText)).toBeVisible() + + await clickAndWaitForResponse(page, 'Go to Optional Page A', '/once-props/optional/a') + + await expect(page).toHaveURL('/once-props/optional/a') + await expect(page.getByText(fooText)).toBeVisible() +}) + +test('merge once prop merges on reload and preserves merged data on navigation', async ({ page }) => { + await page.goto('/once-props/merge/a') + + await expect(page.getByText('Items count: 3')).toBeVisible() + await expect(page.getByText('Bar: bar-a')).toBeVisible() + + await clickAndWaitForResponse(page, 'Load more items', '/once-props/merge/a', 'button') + + await expect(page.getByText('Items count: 6')).toBeVisible() + + await clickAndWaitForResponse(page, 'Go to Merge Page B', '/once-props/merge/b') + + await expect(page).toHaveURL('/once-props/merge/b') + await expect(page.getByText('Bar: bar-b')).toBeVisible() + await expect(page.getByText('Items count: 6')).toBeVisible() + + await clickAndWaitForResponse(page, 'Go to Merge Page A', '/once-props/merge/a') + + await expect(page).toHaveURL('/once-props/merge/a') + await expect(page.getByText('Items count: 6')).toBeVisible() +}) + +test('once prop with custom key shares data across pages with different prop names', async ({ page }) => { + await page.goto('/once-props/custom-key/a') + + await expect(page.getByText('Permissions: perms-a')).toBeVisible() + await expect(page.getByText('Bar: bar-a')).toBeVisible() + + const permissionsText = await page.locator('#permissions').innerText() + + await page.getByRole('link', { name: 'Go to Custom Key Page B' }).click() + + await expect(page).toHaveURL('/once-props/custom-key/b') + await expect(page.getByText('Bar: bar-b')).toBeVisible() + await expect(page.getByText(permissionsText)).toBeVisible() + + await page.getByRole('link', { name: 'Go to Custom Key Page A' }).click() + + await expect(page).toHaveURL('/once-props/custom-key/a') + await expect(page.getByText(permissionsText)).toBeVisible() +}) + +test('prefetched once prop is preserved after navigating through intermediary page', async ({ page }) => { + const prefetchPromise = page.waitForResponse((response) => response.url().includes('/once-props/page-d')) + + await page.goto('/once-props/page-a') + + await expect(page.getByText('Foo: foo-a')).toBeVisible() + await expect(page.getByText('Bar: bar-a')).toBeVisible() + + const fooText = await page.locator('#foo').innerText() + + await prefetchPromise + + await page.getByRole('link', { name: 'Go to Page C' }).click() + await expect(page).toHaveURL('/once-props/page-c') + + await page.getByRole('link', { name: 'Go to Page D' }).click() + await expect(page).toHaveURL('/once-props/page-d') + + await expect(page.getByText(fooText)).toBeVisible() +}) + +test('prefetched once prop is updated when re-prefetched before use', async ({ page }) => { + const prefetchPromise = page.waitForResponse((response) => response.url().includes('/once-props/page-d')) + + await page.goto('/once-props/page-a') + + await expect(page.getByText('Foo: foo-a')).toBeVisible() + await expect(page.getByText('Bar: bar-a')).toBeVisible() + + const fooText = await page.locator('#foo').innerText() + + await prefetchPromise + + await page.getByRole('button', { name: 'Reload (only foo)' }).click() + await expect(page.getByText(fooText)).not.toBeVisible() + + const newFooText = await page.locator('#foo').innerText() + + expect(newFooText.startsWith('Foo: foo-a')).toBe(true) + expect(newFooText).not.toBe(fooText) + + await page.getByRole('link', { name: 'Go to Page D' }).click() + await expect(page).toHaveURL('/once-props/page-d') + + await expect(page.getByText(newFooText)).toBeVisible() +}) + +test('prefetch cache TTL is extended when once prop is reloaded before expiry', async ({ page }) => { + const prefetchPromise = page.waitForResponse((response) => response.url().includes('/once-props/ttl/c')) + + await page.goto('/once-props/ttl/a') + + await expect(page.getByText('Foo: foo-a')).toBeVisible() + const initialFooText = await page.locator('#foo').innerText() + + await prefetchPromise + + await page.getByRole('button', { name: 'Reload foo' }).click() + await expect(page.getByText(initialFooText)).not.toBeVisible() + + const reloadedFooText = await page.locator('#foo').innerText() + + await page.waitForTimeout(1500) + + let requestMade = false + page.on('request', (request) => { + if (request.url().includes('/once-props/ttl/c')) { + requestMade = true + } + }) + + await page.getByRole('link', { name: 'Go to TTL Page C' }).click() + await expect(page).toHaveURL('/once-props/ttl/c') + + expect(requestMade).toBe(false) + await expect(page.getByText(reloadedFooText)).toBeVisible() +}) + +test('prefetch cache is invalidated when extended TTL expires', async ({ page }) => { + test.setTimeout(10000) + const prefetchPromise = page.waitForResponse((response) => response.url().includes('/once-props/ttl/c')) + + await page.goto('/once-props/ttl/a') + + await expect(page.getByText('Foo: foo-a')).toBeVisible() + const initialFooText = await page.locator('#foo').innerText() + + await prefetchPromise + + await page.getByRole('button', { name: 'Reload foo' }).click() + await expect(page.getByText(initialFooText)).not.toBeVisible() + + const reloadedFooText = await page.locator('#foo').innerText() + + await page.waitForTimeout(2500) + + await page.getByRole('link', { name: 'Go to TTL Page C' }).click() + await expect(page).toHaveURL('/once-props/ttl/c') + + const newFooText = await page.locator('#foo').innerText() + expect(newFooText).not.toBe(reloadedFooText) + expect(newFooText.startsWith('Foo: foo-c')).toBe(true) +}) + +test('prefetched once prop with TTL is refreshed after expiry', async ({ page }) => { + const prefetchPromise = page.waitForResponse((response) => response.url().includes('/once-props/ttl/c')) + + await page.goto('/once-props/ttl/a') + + await expect(page.getByText('Foo: foo-a')).toBeVisible() + const initialFooText = await page.locator('#foo').innerText() + + await prefetchPromise + await page.waitForTimeout(2500) + + await page.getByRole('link', { name: 'Go to TTL Page C' }).click() + await expect(page).toHaveURL('/once-props/ttl/c') + + const newFooText = await page.locator('#foo').innerText() + expect(newFooText).not.toBe(initialFooText) + expect(newFooText.startsWith('Foo: foo-c')).toBe(true) +}) + +test('prefetch cache is updated when deferred once prop finishes loading', async ({ page }) => { + const prefetchPromise = page.waitForResponse( + (response) => + response.url().includes('/once-props/deferred/c') && !response.request().headers()['x-inertia-partial-data'], + ) + + await page.goto('/once-props/deferred/a') + + await expect(page.getByText('Loading foo...')).toBeVisible() + + await prefetchPromise + + await page.waitForResponse( + (response) => + response.url().includes('/once-props/deferred/a') && + response.request().headers()['x-inertia-partial-data'] === 'foo', + ) + + await expect(page.getByText('Loading foo...')).not.toBeVisible() + const fooText = await page.locator('#foo').innerText() + expect(fooText.startsWith('Foo: foo-a')).toBe(true) + + await page.getByRole('link', { name: 'Go to Deferred Page C' }).click() + await expect(page).toHaveURL('/once-props/deferred/c') + + await expect(page.getByText(fooText)).toBeVisible() +}) + +test('prefetch cache is updated when replaceProp is called', async ({ page }) => { + const prefetchPromise = page.waitForResponse((response) => response.url().includes('/once-props/page-d')) + + await page.goto('/once-props/page-a') + + await expect(page.getByText('Foo: foo-a')).toBeVisible() + + await prefetchPromise + + await page.getByRole('button', { name: 'Replace foo' }).click() + + await expect(page.getByText('Foo: replaced-foo')).toBeVisible() + + await page.getByRole('link', { name: 'Go to Page D' }).click() + await expect(page).toHaveURL('/once-props/page-d') + + await expect(page.getByText('Foo: replaced-foo')).toBeVisible() +}) + +test('prefetch cache expires based on cacheFor even when once prop has no TTL', async ({ page }) => { + const prefetchPromise = page.waitForResponse((response) => response.url().includes('/once-props/page-e')) + + await page.goto('/once-props/page-a') + + await expect(page.getByText('Foo: foo-a')).toBeVisible() + const fooText = await page.locator('#foo').innerText() + + await prefetchPromise + + await page.waitForTimeout(1500) + + let requestMade = false + page.on('request', (request) => { + if (request.url().includes('/once-props/page-e')) { + requestMade = true + } + }) + + await page.getByRole('link', { name: 'Go to Page E (short cache)' }).click() + await expect(page).toHaveURL('/once-props/page-e') + + expect(requestMade).toBe(true) + await expect(page.getByText('Bar: bar-e')).toBeVisible() + await expect(page.getByText(fooText)).toBeVisible() +})