提交一個 PR 到 Inertia.js

程式技術

最近開始在惡補過去的年度目標,也就是用 Inertia.js 搭配 Svelte 與 Laravel 來寫個 SPA 網站。不得不說不用寫 API,而且前端與後端都能使用自己喜歡的技術,在開發上真的是一件很讓人愉快的事情,。

來說說前幾週向 Inertia.js 提交 PR 的故事。我在開發自己的專案時,意外的發現 Inertia.js 的 Svelte Adapter 有一個小小的錯誤(Bug)🐛,那就是 Form Helperreset() 方法在 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/react
  • packages/vue3
  • packages/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-app
  • packages/vue3/test-app
  • packages/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,讓開源社群更加充滿活力 💪。

還有記得寫測試。

參考資料

Allen
written by
Allen

持續努力中的後端打工仔,在下班後喜歡研究各種不同的技術。稍微擅長 PHP,並偶爾涉獵前端開發。個性就像動態語言般隨興,但渴望做事能像囉嗦的靜態語言那樣嚴謹。

0 則留言
新增留言
訪客 2025 年 11 月 13 日