Skip to content

Commit 859e164

Browse files
authored
Clone page props before writing it to the browser's history (#2662)
* wip * wip * Update history.ts * Update history.ts * Update history.ts
1 parent d683c63 commit 859e164

File tree

4 files changed

+147
-2
lines changed

4 files changed

+147
-2
lines changed

packages/core/src/history.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isEqual } from 'lodash-es'
1+
import { cloneDeep, isEqual } from 'lodash-es'
22
import { decryptHistory, encryptHistory, historySessionStorageKeys } from './encryption'
33
import { page as currentPage } from './page'
44
import Queue from './queue'
@@ -64,9 +64,25 @@ class History {
6464
})
6565
}
6666

67+
protected clonePageProps(page: Page): Page {
68+
try {
69+
structuredClone(page.props)
70+
return page
71+
} catch {
72+
// Props contain non-serializable data (e.g., Proxies, functions).
73+
// Clone them to ensure they can be safely stored in browser history.
74+
return {
75+
...page,
76+
props: cloneDeep(page.props),
77+
}
78+
}
79+
}
80+
6781
protected getPageData(page: Page): Promise<Page | ArrayBuffer> {
82+
const pageWithClonedProps = this.clonePageProps(page)
83+
6884
return new Promise((resolve) => {
69-
return page.encryptHistory ? encryptHistory(page).then(resolve) : resolve(page)
85+
return page.encryptHistory ? encryptHistory(pageWithClonedProps).then(resolve) : resolve(pageWithClonedProps)
7086
})
7187
}
7288

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<script setup lang="ts">
2+
import { Deferred, Link, router } from '@inertiajs/vue3'
3+
import { toRef } from 'vue'
4+
5+
const props = defineProps<{
6+
foo: number
7+
sites?: Array<{
8+
id: number
9+
latestDeployment: {
10+
id: number
11+
statuses: string[]
12+
}
13+
}>
14+
}>()
15+
16+
const sites = toRef(props, 'sites')
17+
18+
const updateFirstSite = () => {
19+
if (sites.value && sites.value.length > 0) {
20+
const incomingPartialData = { statuses: [`frontend-${Math.floor(Math.random() * 1_000_000)}`] }
21+
sites.value[0].latestDeployment = { ...sites.value[0].latestDeployment, ...incomingPartialData }
22+
}
23+
}
24+
25+
function submit() {
26+
router.post(
27+
'/visits/proxy',
28+
{},
29+
{
30+
preserveScroll: true,
31+
preserveState: true,
32+
only: ['foo'],
33+
},
34+
)
35+
}
36+
</script>
37+
38+
<template>
39+
<p id="foo">Foo: {{ props.foo }}</p>
40+
41+
<Deferred data="sites">
42+
<template #fallback>Loading...</template>
43+
44+
<div v-for="site in sites" :key="site.id">
45+
<p>Site ID: {{ site.id }}</p>
46+
<p>Latest Deployment ID: {{ site.latestDeployment.id }}</p>
47+
<p :id="`status-${site.id}`">Statuses: {{ site.latestDeployment.statuses.join(', ') }}</p>
48+
</div>
49+
</Deferred>
50+
51+
<button @click="updateFirstSite">Update First Site Ref</button>
52+
<button @click="submit">Reload</button>
53+
<Link href="/">Go Home</Link>
54+
</template>

tests/app/server.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,31 @@ app.get('/client-side-visit/props', (req, res) =>
166166
}),
167167
)
168168

169+
app.get('/visits/proxy', (req, res) => {
170+
const timeout = req.headers['x-inertia-partial-data'] ? 250 : 0
171+
const statuses = ['pending', 'running', 'success', 'failed', 'canceled']
172+
173+
const sites = [1, 2, 3, 4, 5].map(function (id) {
174+
const site = { id }
175+
176+
site.latestDeployment = { id: id * 10, statuses: [statuses[id % statuses.length]] }
177+
178+
return site
179+
})
180+
181+
setTimeout(
182+
() =>
183+
inertia.render(req, res, {
184+
component: 'Visits/Proxy',
185+
props: req.headers['x-inertia-partial-data'] === 'sites' ? { sites } : { foo: new Date().toISOString() },
186+
deferredProps: req.headers['x-inertia-partial-data'] ? {} : { default: ['sites'] },
187+
}),
188+
timeout,
189+
)
190+
})
191+
192+
app.post('/visits/proxy', (req, res) => res.redirect(303, '/visits/proxy'))
193+
169194
app.get('/visits/partial-reloads', (req, res) =>
170195
inertia.render(req, res, {
171196
component: 'Visits/PartialReloads',

tests/manual-visits.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,3 +1015,53 @@ test('can do a subsequent visit after the previous visit has thrown an error in
10151015
await expect(dump.method).toBe('get')
10161016
await expect(dump.form).toEqual({})
10171017
})
1018+
1019+
test('vue proxies synced back to the core adapter are not stored in history state', async ({ page }) => {
1020+
test.skip(process.env.PACKAGE !== 'vue3', 'Vue 3 specific test')
1021+
1022+
pageLoads.watch(page)
1023+
await page.goto('/visits/proxy')
1024+
await expect(page.getByText('Site ID: 1')).toBeVisible()
1025+
1026+
const fooText = await page.locator('#foo').innerText()
1027+
const statusText = await page.locator('#status-1').innerText()
1028+
await expect(statusText).toBe('Statuses: running')
1029+
1030+
await page.getByRole('button', { name: 'Update First Site Ref' }).click()
1031+
1032+
const newStatusText = await page.locator('#status-1').innerText()
1033+
await expect(newStatusText).not.toBe(statusText)
1034+
await expect(newStatusText.startsWith('Statuses: frontend-')).toBeTruthy()
1035+
1036+
await page.getByRole('button', { name: 'Reload' }).click()
1037+
await expect(page.locator('#foo')).not.toHaveText(fooText)
1038+
await expect(await page.locator('#status-1').innerText()).toBe(newStatusText)
1039+
1040+
const newFooText = await page.locator('#foo').innerText()
1041+
1042+
// Navigate away...
1043+
await page.getByRole('link', { name: 'Go Home' }).click()
1044+
await expect(page).toHaveURL('/')
1045+
1046+
// Go back...
1047+
await page.goBack()
1048+
await expect(page).toHaveURL('/visits/proxy')
1049+
1050+
// Ensure state is preserved...
1051+
await expect(page.locator('#foo')).toHaveText(newFooText)
1052+
await expect(await page.locator('#status-1').innerText()).toBe(newStatusText)
1053+
1054+
// Update ref again...
1055+
await page.getByRole('button', { name: 'Update First Site Ref' }).click()
1056+
1057+
const updatedStatusText = await page.locator('#status-1').innerText()
1058+
await expect(updatedStatusText).not.toBe(newStatusText)
1059+
await expect(updatedStatusText.startsWith('Statuses: frontend-')).toBeTruthy()
1060+
1061+
// Reload again...
1062+
await page.getByRole('button', { name: 'Reload' }).click()
1063+
await expect(page.locator('#foo')).not.toHaveText(newFooText)
1064+
1065+
// Ensure updated status is still there
1066+
await expect(await page.locator('#status-1').innerText()).toBe(updatedStatusText)
1067+
})

0 commit comments

Comments
 (0)