提交一個 PR 到 Inertia.js
最近開始在惡補過去的年度目標,也就是用 Inertia.js 搭配 Svelte 與 Laravel 來寫個 SPA 網站。不得不說不用寫 API,而且前端與後端都能使用自己喜歡的技術,在開發上真的是一件很讓人愉快的事情,。
來說說前幾週向 Inertia.js 提交 PR 的故事。我在開發自己的專案時,意外的發現 Inertia.js 的 Svelte Adapter 有一個小小的錯誤(Bug)🐛,那就是 Form Helper 的 reset() 方法在 onSuccess() 回呼函式(Callback)中是不起作用的。簡單舉個例子 🌰。
假設有一個讓用戶修改密碼的表單,用戶需要輸入現在的密碼與新的密碼來更新密碼,如果更新成功的話,就把表單的內容清空。
import { useForm } from '@inertiajs/svelte'
import { update } from 'App/Http/Controllers/PasswordController'
// 建立一個 Form Helper,將其用在更改用戶密碼的表單
const form = useForm({
current_password: "", // 用戶現在的密碼
password: "", // 新的密碼
password_confirmation: "", // 重複確認新的密碼
});
// 當用戶填寫好更改密碼的表單,並提交表單後
// 我認爲 onSuccess() 回呼函式中的 $form.reset() 會將所有輸入框的值清空
// 但這裡貌似有 Bug,表單中輸入框的值仍會保持用戶原本填寫的值,並不會清空
$form.put(update().url, {
preserveScroll: true,
// $form.reset() 在 onSuccess 回呼函式中不起作用
onSuccess: () => $form.reset(),
onError: (errors: any) => {
// 詭異的是,$form.reset() 在 onError 回呼函式是正常運作的
$form.reset()
},
});這個問題在 Vue Adapter 中並沒有出現,讓我嗅到了一股提交 PR(Pull Request)的機會,我想試試看去解決這個問題,這也是一個拜讀 Inertia.js 程式碼的好機會,隨後我 Folk 了 Inertia.js 專案到本地端,開始研究問題的發生原因。
調查問題產生的原因
Debug 需要看到結果才能比較好 Debug,因此我修改了一下自己專案的 package.json ,將 Inertia.js 的套件來源,改為本地端的 Inertia.js 專案。
{
"devDependencies": {
"@inertiajs/svelte": "file:/path/to/my/local/inertia",
}
}這樣我對 Inertia.js 程式碼的任何修改,結果都會直接反應到我的專案畫面上。
其實 Inertia.js 有提供測試應用程式,你也可以在上面看修改的顯示結果。
來看看 Inertia.js 的程式碼
Inertia.js 的資料夾結構相當清楚,所有的 Adapter 都是單獨自己一個資料夾放在 packages 中:
packages/reactpackages/vue3packages/svelte
既然問題發生在 useForm() 函式中,當然要先來找 useForm() 是在哪裡被定義的。往 svelte 資料夾裡面一找,很快就發現了 useForm.ts 檔案。
// packages/svelte/src/useForm.ts
// 使用函式多載定義的 useForm() 函式
export default function useForm<TForm extends FormDataType>(data: TForm | (() => TForm)): Writable<InertiaForm<TForm>>
export default function useForm<TForm extends FormDataType>(
rememberKey: string,
data: TForm | (() => TForm),
): Writable<InertiaForm<TForm>>
export default function useForm<TForm extends FormDataType>(
rememberKeyOrData: string | TForm | (() => TForm),
maybeData?: TForm | (() => TForm),
): Writable<InertiaForm<TForm>> {
//...
}使用 useForm() 回傳的 Form Helper 擁有多種 HTTP 請求的提交方法,用來提交用戶填寫的表單,例如 get()、post() 與 delete()。但不論是哪種 HTTP 請求方法,其實都是 submit() 方法的包裝。
// inertia/packages/svelte/src/useForm.ts
// Svelte 的 Store 可以讓外部隨時透過訂閱來得知物件內部數值的變更
const store = writable<InertiaForm<TForm>>({
submit(...args) {
// ...
},
// ...
// 不論是 get、post、put 或者是 delete,都是 submit() 方法的包裝
get(url, options) {
this.submit('get', url, options)
},
post(url, options) {
this.submit('post', url, options)
},
// ...
});往 submit() 方法裡面一看,會看到有一個 onSuccess() 函式的實作。仔細一看,其實 onSuccess() 會先執行幾個固定的任務之後,在最後才去執行外部傳入的 onSuccess() 回呼函式。
// inertia/packages/svelte/src/useForm.ts
// 外部傳入的 onSuccess 回呼函式,會被放在 options 中
const options = (objectPassed ? args[1] : args[2]) ?? {}
const _options: Omit<VisitOptions, 'method'> = {
// 解包 options,將外部傳入的 onSuccess() 回呼函式取出
...options,
// 外部傳入的 onSuccess() 回呼函式會再被這個同名的函式蓋過去
onSuccess: async (page: Page) => {
// 在執行外部傳入的 onSuccess 之前,內部會先執行其他任務
this.setStore('processing', false)
this.setStore('progress', null)
this.clearErrors()
this.setStore('wasSuccessful', true)
this.setStore('recentlySuccessful', true)
this.defaults(cloneDeep(this.data()))
recentlySuccessfulTimeoutId = setTimeout(() => this.setStore('recentlySuccessful', false), 2000)
// 外部傳入的回呼函式,會在這裡被執行
if (options.onSuccess) {
return options.onSuccess(page)
}
},
// ...
} as InertiaForm<TForm>)問題很有可能就在這裡,但是我左看右看,不停的用 console.log() 反覆 Debug,卻看不太出來哪裡有問題。
我原本想用 IDE 的 Debugger 來找問題,但是我一直無法在 TypeScript 中成功觸發 Break Point,所以就只能放棄改為使用最入門的
consol.log()了 😂,希望有看到這篇文章的大大能告訴我怎麼在 TypeScript 中使用 Debugger。
後來我想到也許可以看一下 Vue Adapter 中 onSuccess() 函式的寫法,看看有哪裡跟 Svelte Adapter 是不同的,也許就能找出有問題的地方。因為這個問題在 Vue Adapter 中是不存在的。
比對一下兩邊的程式碼之後,我發現有個地方不太一樣。
首先看看沒有問題的 Vue Adapter。在 onSuccess() 中,外部傳入的 onSuccess() 回呼函式並不是最後一個執行的,後面還有一行會修改 defaults 變數,也就是表單的預設值。
// inertia/packages/vue3/src/useForm.ts
onSuccess: async (page) => {
// ...
const onSuccess = options.onSuccess ? await options.onSuccess(page) : null
// 將表單輸入框目前的值設定為預設值
defaults = cloneDeep(this.data())
// ...
},但是在 Svelte Adapter,會先執行 defaults() 方法來修改表單的預設值,然後才去執行外部傳入的 onSuccess() 回呼函式。
// inertia/packages/svelte/src/useForm.ts
onSuccess: async (page: Page) => {
// ...
// 將表單輸入框目前的值設定為預設值
this.defaults(cloneDeep(this.data()))
// ...
if (options.onSuccess) {
return options.onSuccess(page)
}
}這麼看來問題就很明顯了,當我試圖在 onSuccess() 回呼函式中使用 $form.reset() 重設表單的預設值時,其實預設值早就已經被修改成輸入框目前的值了!因此從用戶的角度來看,表單並不會被清空,而是繼續保持現有的值。
這看起來確實是一個問題,想要修復這個問題也很容易,把任務執行的順序調整成跟 Vue Adapter 一樣即可。
// inertia/packages/svelte/src/useForm.ts
onSuccess: async (page: Page) => {
// ...
let onSuccess = null
if (options.onSuccess) {
onSuccess = options.onSuccess(page)
}
// 在執行外部傳入的 onSucess 回呼函式後才修改表單的預設值
this.defaults(cloneDeep(this.data()))
return onSuccess
}在專案中測試後,我確認 Form Helper 的 reset() 方法已經能成功的在 onSuccess() 回呼函式中將輸入框的值還原至預設值。接著執行一下功能測試,發現測試全部通過!
我:OK!準備開個新分支提出 PR 吧!
正義之聲:修但幾咧,你的測試咧?

等等,你的測試呢!?
哈,上面的圖片是開玩笑的啦 😆!一個好的 PR,大概率都離不開完善的測試。雖然這次的問題只是程式碼執行順序出錯了,而且在修改程式碼後,也沒有破壞原本的功能測試,乍看之下好像可以不寫測試?
但如果程式碼的修改與否都不影響測試,這也代表原本的測試並沒有覆蓋到部分情境,所以仍有必要補上相關測試。
我:話雖這麼說,我還是先提交沒有測試的 PR 了 😈。
正義之聲:你這個厚顏無恥之人!
我:時間很晚了,我想先去睡覺,明天起床再來寫測試 😪。
提交 PR 與說明緣由
建議一定要開一個新的分支來提交 PR,而不是在主要分支上提交 PR。還有一定要巨細靡遺的說明 PR 的緣由。以我這個修正錯誤的 PR 為例,我會在說明中列出以下三點:
- 什麼情況下會遇到這個問題
- 這個問題發生的原因是什麼
- 我怎麼修正這個問題
說明的越精確,你的 PR 被合併的機會就越高喔!
我:另外就是還要看你有沒有寫測試。
正義之聲:看來你還是有良心的…
來補測試吧!
隔天早上一覺醒來收到通知。維護者很快的 Review 我的 PR(這也太快!),他也覺得這是個錯誤,並希望我補上測試,還很貼心的跟我說測試可以寫在哪裡。

我:沒問題!立刻來補測試 🫡。
Inertia.js 是使用 Playwright 這個 E2E(End-to-End)測試框架來編寫測試。測試資料夾的架構非常清楚的寫在貢獻文件中,所有的測試都放在 tests 資料夾底下,而裡面的每一個測試,都會在 React、Vue 還有 Svelte 的測試應用程式中各跑一遍。
也就是說…
除了寫測試,你還需要寫三種版本的測試應用程式。

測試應用程式放在各個 Adapter 的資料夾中:
packages/react/test-apppackages/vue3/test-apppackages/svelte/test-app
這邊就以 Svelte Adapter 為例,開始寫測試吧!
首先修改 Svelte Adapter 的測試應用程式,在原本的表單中加上測試需要的按鈕與輸入框,並新增一個 onSuccessResetValue() 函式來重設表單的預設值。
<!-- packages/svelte/test-app/Pages/FormHelper/Events.svelte -->
<script>
// ...
const onSuccessResetValue = () => {
$form.post($page.url, {
...callbacks({
onSuccess: (page) => {
$form.reset()
},
}),
})
}
//...
</script>
<!-- ... -->
<!-- 新增一個按鈕,點擊之後會執行 onSuccessResetValue() 函式 -->
<button on:click|preventDefault={onSuccessResetValue} class="success-reset-value">onSuccess resets value</button>
<!-- ... -->
<!-- 在表單中新增幾個輸入框,有文字類型也有 checkbox 類型 -->
<input type="text" class="name-input" bind:value={$form.name} />
<input type="checkbox" class="remember-input" bind:checked={$form.remember} />接著加上一個新的測試案例。
// tests/form-helper.ts
test('resets the input value to the default value', async ({ page }) => {
// 先斷定原本輸入框的值,確保為預設值
await expect(page.locator('.name-input')).toHaveValue('foo')
await expect(page.locator('.remember-input')).not.toBeChecked()
// 修改輸入框的值
await page.fill('.name-input', 'bar')
await page.check('.remember-input')
// 斷定目前輸入框的值
await expect(page.locator('.name-input')).toHaveValue('bar')
await expect(page.locator('.remember-input')).toBeChecked()
// 點擊按鈕來重設表單
await clickAndWaitForResponse(page, 'onSuccess resets value', null, 'button')
// 斷定目前輸入框的值已回到最一開始的值
await expect(page.locator('.name-input')).toHaveValue('foo')
await expect(page.locator('.remember-input')).not.toBeChecked()
})其他 Adapter 也是一模一樣的寫法,只是語法不同。跑一下新加入的測試確定沒問題後,我提交了新的 Commit 到原本的 PR 分支上。我留言說明補上了測試,並且大大的稱讚了一下貢獻文件寫得超好,對我這種前端新手來說幫助很大。

我的上一篇文章其實就是參考這位維護者大大的影片,非常感謝 👍。
過了幾個小時後,我的 PR 就被合併進 Inertia.js 的主分支了(太快了吧!)。
提 PR 的正確姿勢有哪些?
說到提交 PR 的正確姿勢,我很推薦下面這部影片,講者分享了過去提交 PR 到 Laravel 專案的經驗,基本上把所有提交 PR 時你需要注意的事情都提到了。
好的 PR 不外乎具備下面幾點:
- 盡可能少量的修改
- 沒有破壞性更新
- 完整的說明
- 完善的測試
後面三點應該都很好理解,第一點之所以要求是盡可能少量的修改,是因為一個 PR 如果有大量的修改,絕對會讓一位維護者不知道該從哪裡開始 Code Review。

這次從找問題到提交 PR 被合併,整個過程不到兩天,卻讓我學到了不少前端的知識。雖然只是一個很小的問題,但自己提交的 PR 成功合併進去擁有 7,000 個星星的專案,還是讓我的心情有點雀躍 😊。所以就想來寫一篇文章,用比較幽默的方式來記錄這次提交 PR 的過程。
最後的最後,希望大家都能一起來貢獻品質良好的 PR,讓開源社群更加充滿活力 💪。
還有記得寫測試。