บทเรียน 135 · Rails · Day 3

Lab Vibe 3 — ตรวจรับงาน Hotwire และสรุป Day 3 ช่วงเช้า

~30 นาทีอ่าน + ตรวจตามในเครื่องตัวเองอีก ~20 นาที · ควรผ่าน บท 131–134 (Turbo Frame · Turbo Stream · broadcast · Stimulus) มาก่อน

บท 131–134 คุณสร้างของ Hotwire ครบแล้วทีละชิ้น · บทนี้ไม่มีโค้ดแอปใหม่ แต่จะสวมบท "คนตรวจรับงาน": เอาใบตรวจรับ 7 ข้อของสไลด์มาไล่ทีละข้อด้วยของจริง (Chrome สองหน้าต่าง · grep · log)
อ่านจบแล้วคุณจะ ตรวจงาน Hotwire ที่ใครก็ตามส่งมา (รวมถึง AI) ได้ในไม่กี่นาที · รู้ว่าอาการแต่ละแบบแปลว่า AI พลาดตรงไหน · มีไฟล์ prompts/03-hotwire.md กับกฎข้อ 9–12 ไว้สั่งงานครั้งหน้า · และเห็นภาพรวมว่าเช้านี้ทั้งเช้าได้อะไรไปบ้าง

🔁 ทวนก่อนเริ่ม — ทั้งบทใช้คำพวกนี้ ถ้าข้อไหนไม่คุ้นให้กลับไปบทนั้นก่อน

🍜 เริ่มจากโค้ดสถานะไหน: ต่อจากตอนจบบท 134 ที่ commit แล้ว (git status --short ว่าง) — หน้า /posts/1 แก้หัวข้อในที่เดิมได้ · ส่งคอมเมนต์ได้และเด้งทุกแท็บ · ช่องพิมพ์คอมเมนต์มีตัวนับ "0 / 1000" · ถ้าตามไม่ทัน กลับไปบท 131–134 ตามลำดับ · บทนี้เพิ่มแค่สองไฟล์ใน prompts/ · จบบทโค้ดของคุณคือ จุดเช็คพอยต์ C (จบ Day 3 ช่วงเช้า)

🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 18 · turbo-rails 2.0.23 · stimulus-rails 1.3.4 · Chrome (สั่งด้วยสคริปต์ ไม่ได้กดเอง) (สไลด์กับ repo ของอาจารย์เขียน Ruby 4.0.5 · Rails 8.1.3 — เรื่องในบทนี้ไม่ต่างกัน)

ผลรันทุกก้อนในบทนี้รันจริงแล้วก็อปมา · ค่าที่เปลี่ยนทุกครั้ง เช่น เวลา ms · เลข id · เลข commit · วันเวลา · ชื่อไฟล์ migration ของคุณจะต่าง · พฤติกรรมในเบราว์เซอร์ตรวจด้วย Chrome จริงที่สั่งด้วยสคริปต์ · log ตัดรหัสสีออกแล้ว

Lab นี้คือวันตรวจรับ ไม่ใช่วันสร้าง

สไลด์หน้า 52 ให้ Lab นี้เป็น "เขียน prompt ให้ AI ทำทั้งบล็อก แล้วตรวจ" · แต่ในเว็บนี้เราเลือกให้คุณทำเองเป็นก่อน — คุณจึงทำครบทั้ง 3 เป้าหมายไปแล้วในบท 131–134 · สิ่งที่เหลือคือส่วนที่สำคัญที่สุดของการทำงานกับ AI: ตรวจรับให้เป็น

เป้าหมายในสไลด์หน้า 52ทำไปแล้วในบทไฟล์หลักที่ได้
1) แก้หัวข้อโพสต์ได้ในหน้าเดิม ไม่ต้องเปลี่ยนหน้า131 Turbo Frameposts/_post.html.erb · posts/edit.html+turbo_frame.erb
2) เพิ่ม Comment ได้ และเด้งขึ้นทุกแท็บที่เปิดอยู่132 Turbo Stream (ตอบฟอร์ม)comments_controller.rb · comments/create.turbo_stream.erb
133 broadcastmodels/comment.rb (broadcasts_to) · turbo_stream_from ใน posts/show.html.erb
3) ช่องพิมพ์ comment มีตัวนับอักษร134 Stimuluscontrollers/character_counter_controller.js · comments/_form.html.erb

Lab นี้มี 5 ขั้น:

ขั้น 1 เขียนใบสั่งงาน prompts/03-hotwire.md (ทุกบรรทัดมาจากบท 131–134) │ ขั้น 2 ตรวจรับ 7 ข้อ Chrome สองหน้าต่าง + Console + curl │ ขั้น 3 ตรวจ 4 อย่างด้วยคำสั่ง grep · และใส่บั๊กกลับเพื่อเชื่อตัวตรวจ │ ขั้น 4 เติมกฎ 9–12 prompts/00-context.md (8 ข้อ → 12 ข้อ) │ ขั้น 5 เก็บเข้าโปรเจกต์ git commit │ (ทางเลือก) ให้ AI ทำบล็อกนี้ใหม่ทั้งหมด แล้วตรวจด้วย checklist ชุดเดียวกัน
🍽 เทียบให้เห็นภาพ — วันตรวจรับร้านหลังปรับปรุง

ช่างมาติดตั้งของใหม่ในร้าน 4 รอบ (บท 131–134): ถ้วยที่เปลี่ยนได้ทีละใบ · ใบสั่งงานหลายข้อ · เสียงตามสาย · นาฬิกาจับเวลาบนโต๊ะ · วันนี้เจ้าของร้านเดินถือใบตรวจรับ 7 ข้อ ไล่ลองของจริงทีละข้อ ก่อนเซ็นรับงาน · ใบตรวจรับเดียวกันนี้ใช้ได้กับช่างทุกเจ้า ไม่ว่าคนหรือ AI

ที่ไม่เหมือนกัน: ตรวจรับร้านจริงทำครั้งเดียวจบ · ของเราตรวจซ้ำได้ฟรีทุกครั้งที่โค้ดเปลี่ยน และควรตรวจซ้ำ — โค้ดที่ผ่านเมื่อวานพังได้วันนี้ ถ้ามีใคร (หรือ AI) ไปแก้ไฟล์ข้าง ๆ

🗣 ศัพท์ของขั้นนี้
acceptance criteria (เกณฑ์ตรวจรับ)
รายการข้อที่ต้องจริงทุกข้อ งานถึงจะนับว่า "เสร็จ" · ส่วนที่ 4 ของ prompt 4 ส่วน (บท 89) · ถาม senior ได้ว่า "งานนี้มี acceptance criteria อะไรบ้างครับ จะได้ตรวจเองก่อนส่ง review"
manual test · verify
manual test = ลองใช้งานเองด้วยมือตามขั้นตอนที่กำหนด · verify = พิสูจน์ว่าจริง ไม่ใช่แค่เชื่อ · พิมพ์บอก AI ได้ว่า "Don't tell me it works. Give me the exact steps to verify each acceptance criterion in the browser."

ขั้น 1 — ใบสั่งงาน prompts/03-hotwire.md: ทุกบรรทัดมาจากบทไหน

ไฟล์นี้คือใบสั่งงานที่จะส่งให้ AI ทำบล็อก C ทั้งหมดในครั้งเดียว · หน้าตาเหมือน 01-views.md (บท 126) กับ 02-styling.md (บท 129): เป้าหมาย · ข้อกำหนด · ห้าม · เกณฑ์ตรวจรับ — สี่ส่วนของบท 121

ตอนนี้ ls prompts ควรเห็น 3 ไฟล์ (00-context.md · 01-views.md · 02-styling.md) · บท 134 ฝากไฟล์นี้ไว้ให้บทนี้ — สร้างไฟล์ prompts/03-hotwire.md แล้วพิมพ์ตามนี้ (ทั้งไฟล์ — ก็อปมาจากสไลด์หน้า 52 · แก้บรรทัด broadcasts_to หนึ่งจุด และใส่ backtick รอบ <script>):

## เป้าหมาย

1) แก้หัวข้อโพสต์ได้ในหน้าเดิม ไม่ต้องเปลี่ยนหน้า
2) เพิ่ม Comment ได้ และเด้งขึ้นทุกแท็บที่เปิดอยู่
3) ช่องพิมพ์ comment มีตัวนับอักษร

## ข้อกำหนด

- สร้าง model Comment: body:text author_name:string
  post:references (ยังไม่มีระบบล็อกอิน)
- validation + NOT NULL + index ครบคู่
- inline edit ใช้ turbo-frame
  ใช้ dom_id(post) ตั้ง id ทั้งสองฝั่ง
- เพิ่ม comment ตอบด้วย create.turbo_stream.erb
  append รายการ + update ตัวนับ + ล้างฟอร์ม
- real-time ใช้ broadcasts_to :post
  (มันส่งต่อให้ job เองอยู่แล้ว ไม่มี option later:)
- ตัวนับอักษรเป็น Stimulus controller
  ต้องมี disconnect ที่เก็บกวาด listener

## ห้าม

- เขียน addEventListener นอก Stimulus
- ใส่ `<script>` ใน view
- hardcode id ของ turbo-frame เป็น string
- ใช้ after_save (ต้องเป็น after_commit)
- ลืม status: :unprocessable_content ตอน render ใหม่

## เกณฑ์ตรวจรับ

- [ ] กดแก้ไขแล้ว URL ไม่เปลี่ยน และส่วนอื่นไม่ขยับ
- [ ] เปิด 2 แท็บ พิมพ์แท็บหนึ่ง อีกแท็บขึ้นเอง
- [ ] ตัวนับจำนวน comment อัปเดตพร้อมกัน
- [ ] ฟอร์มว่างเองหลัง submit
- [ ] Stimulus controller มี disconnect
- [ ] เปิด /posts/1/edit ตรง ๆ ยังได้หน้าเต็ม
- [ ] console ไม่มี error เรื่อง matching turbo-frame

## = หัวข้อของ Markdown · - [ ] = ช่องติ๊กที่ยังไม่ติ๊ก (ติ๊กแล้วเขียน - [x]) · `<script>` ครอบด้วย backtick ให้ AI อ่านว่าเป็น "ชื่อแท็ก" ไม่ใช่แท็กจริง · ทุกบรรทัดของข้อกำหนดกับข้อห้ามมาจากเรื่องที่คุณเจอมาแล้ว:

บรรทัดในใบสั่งเรียนในบททำไมต้องเขียนบอก AI (ถ้าไม่เขียน AI มักทำแบบไหน)
model Comment · validation + NOT NULL + index ครบคู่132 (ทวน 110 · 111)generator ไม่ใส่ null: false ให้ช่อง body กับ author_name — AI ที่สั่ง generate แล้วไม่แก้ต่อจะได้แค่ validation ฝั่ง Rails
inline edit ใช้ turbo-frame · dom_id ทั้งสองฝั่ง131ถ้าฝั่งหนึ่งพิมพ์ id เอง อีกฝั่งใช้ helper — สะกดไม่ตรงกันนิดเดียว ถ้วยก็หาไม่เจอ
create.turbo_stream.erb · append + update + ล้างฟอร์ม132ระบุ 3 จุดชัด ๆ ไม่งั้น AI มักทำแค่ append แล้วลืมตัวนับกับฟอร์ม
broadcasts_to :post133บอกชื่อเครื่องมือตรง ๆ · บรรทัดในวงเล็บคือจุดที่เราแก้จากสไลด์ (ดูกล่องเตือนข้างล่าง)
Stimulus controller ต้องมี disconnect134AI ชอบเขียน JS แบบ <script> + querySelector ในหน้า เพราะเห็นตัวอย่างแบบนั้นบนเน็ตเยอะ
ห้าม 5 ข้อ130–134แต่ละข้อคือ "ที่ AI พลาดประจำ" ของสไลด์หน้า 53 — หัวข้อถัดจากขั้น 3 โชว์ว่าพลาดแล้วอาการเป็นยังไง

⚠️ จุดที่แก้เนื้อหาจากสไลด์: สไลด์หน้า 52 เขียน broadcasts_to :post พร้อม later: true · ใน turbo-rails 2.0.23 broadcasts_to ไม่มีตัวเลือก later: และส่งงานต่อให้ job เองอยู่แล้ว (job = งานที่ server เก็บไว้ทำต่อเบื้องหลังหลังตอบลูกค้าไปแล้ว · บท 133 · ละเอียดใน Day 3 Part 2) · ถ้าส่งตามสไลด์ AI จะเขียน later: true ที่ไม่มีผลอะไร แล้วคุณจะเสียเวลาสงสัยว่ามันทำอะไร — เราจึงเขียนบอกไว้ในใบสั่งเลย

ขั้น 2 — ตรวจรับ 7 ข้อของสไลด์หน้า 52 ด้วยของจริง

สไลด์หน้า 52 มีประโยคที่สำคัญที่สุดของ Lab นี้: เกณฑ์ข้อ 2 (สองแท็บ) พิสูจน์ด้วยการรัน ไม่ใช่ด้วยการอ่านโค้ด — ซึ่งเป็นแบบที่ดีที่สุด เพราะ AI โกงไม่ได้ · AI เขียนในคำตอบได้ว่า "ทำครบแล้ว ผ่านทุกข้อ" แต่มันทำให้คอมเมนต์เด้งในหน้าต่างที่สองของคุณด้วยคำพูดไม่ได้

🍽 เทียบให้เห็นภาพ — อ่านสูตร vs ชิมจริง

พ่อครัวยื่นสูตรมาบอกว่า "ใส่น้ำปลาแล้ว ใส่มะนาวแล้ว" — อ่านสูตรแล้วดูครบ แต่ยังไม่รู้ว่าอร่อยไหม · เจ้าของร้านที่ดีตักชิมเอง และให้คนสองโต๊ะสั่งพร้อมกัน ดูว่าเสียงตามสายดังถึงทั้งสองโต๊ะจริงไหม · อ่านโค้ด = อ่านสูตร · เปิดเบราว์เซอร์สองหน้าต่างลองเอง = ชิม

ที่ไม่เหมือนกัน: การชิมอาหารบอกได้ทุกอย่างที่ลูกค้าจะเจอ · แต่การลองในเบราว์เซอร์บอกไม่ได้ทุกอย่าง — บางบั๊กไม่มีอาการให้เห็นด้วยตา (เช่น ลืม disconnect ในขั้น 3) จึงต้องมีตรวจด้วยคำสั่งควบคู่กันเสมอ

เตรียมโต๊ะตรวจ

  1. แท็บ terminal ที่ 1: bin/dev (เปิดร้าน + ตัว build CSS · บท 128)
  2. เปิด Chrome สองหน้าต่าง (ไม่ใช่สองแท็บในหน้าต่างเดียว — จะได้เห็นพร้อมกัน) วางซ้าย-ขวา · ทั้งสองเปิด http://localhost:3000/posts/1
  3. ในหน้าต่างซ้ายเปิด Console: Mac กด Cmd+Option+J · Windows กด Ctrl+Shift+J · Console คือกระดานแจ้งปัญหาของหน้าเว็บ — JavaScript ที่พังหรือคำเตือนของ Turbo จะขึ้นเป็นตัวแดงที่นี่ (หน้าเว็บเองไม่โชว์อะไรให้เห็นเลย)

⚠️ ระวังสับสน — หน้านี้มี "ตัวนับ" สองตัว: ตัวนับจำนวนคอมเมนต์ ในหัวข้อ "ความคิดเห็น (0)" (มาจาก Turbo Stream + broadcast · บท 132–133) กับตัวนับอักษร "0 / 1000" ใต้ช่องพิมพ์ (มาจาก Stimulus · บท 134) · เกณฑ์ข้อ 3 พูดถึงตัวแรก · เกณฑ์ข้อ 5 พูดถึงตัวหลัง

ตารางตรวจรับ (ผลจริงที่ผมได้ จาก Chrome ที่สั่งด้วยสคริปต์บนโค้ดสถานะเดียวกับของคุณ)

#เกณฑ์ทำยังไงควรเห็น (ผลจริง)
1กดแก้ไขแล้ว URL ไม่เปลี่ยน และส่วนอื่นไม่ขยับ หน้าต่างซ้าย กด "แก้ไขหัวข้อ" · แก้เป็นข้อความใหม่ · กด "บันทึก" หัวข้อกลายเป็นช่องพิมพ์ที่เดิม (เคอร์เซอร์อยู่ในช่องเลย) · กดบันทึกแล้วหัวข้อใหม่ขึ้นที่เดิม · URL ยังเป็น /posts/1 ตลอด
2เปิด 2 แท็บ พิมพ์แท็บหนึ่ง อีกแท็บขึ้นเอง หน้าต่างซ้าย พิมพ์ชื่อ + ความคิดเห็น แล้วกด "ส่งความคิดเห็น" · ไม่แตะหน้าต่างขวาเลย คอมเมนต์โผล่ในหน้าต่างขวาเอง · ของผมใช้ 104 ms นับจากกดส่ง (ของคุณจะต่าง แต่ควรรู้สึกว่า "ทันที")
3ตัวนับจำนวน comment อัปเดตพร้อมกัน ดูหัวข้อ "ความคิดเห็น (…)" ทั้งสองหน้าต่าง ทั้งซ้ายและขวาเปลี่ยนจาก (0) เป็น (1)
4ฟอร์มว่างเองหลัง submit ดูฟอร์มในหน้าต่างซ้ายหลังกดส่ง ช่องชื่อกับช่องความคิดเห็นว่าง · ตัวนับอักษรกลับเป็น 0 / 1000 (ตอนพิมพ์ "อร่อยมาก" มันขึ้น 8 / 1000)
5Stimulus controller มี disconnect คำสั่ง grep (ขั้น 3) + ลองใส่ console.log (ข้างล่าง) เจอ disconnect() { ในไฟล์ตัวนับ · และ Console ขึ้น disconnect → connect ทุกครั้งที่ฟอร์มถูกแทน
6เปิด /posts/1/edit ตรง ๆ ยังได้หน้าเต็ม พิมพ์ localhost:3000/posts/1/edit ในช่อง URL แล้วกด Enter หน้า "แก้ไขบทความ" เต็มหน้า มี navbar · ฟอร์ม 3 ช่อง (หัวข้อ · เนื้อหา · หมวด) ไม่ใช่แค่ช่องหัวข้อ
7console ไม่มี error เรื่อง matching turbo-frame ดู Console หลังทำข้อ 1–6 ครบ ว่างทั้งสามหน้าต่างที่ผมเปิด (ซ้าย · ขวา · หน้า edit ตรง ๆ)

ระวังเข้าใจผิดในข้อ 1: หลังบันทึกหัวข้อใหม่ แถบทางกลับด้านบน (breadcrumb จากบท 126) และชื่อบนแท็บเบราว์เซอร์ยังเป็นหัวข้อเดิม — ผลจริง: กรอบโชว์ ต้มยำกุ้ง (แก้ในหน้าเดิม) แต่ breadcrumb ยังเป็น ต้มยำกุ้งน้ำข้น

นี่ไม่ใช่บั๊ก · Turbo Frame เปลี่ยนแค่ "ถ้วยใบเดียว" ตามที่ตั้งใจ (บท 131) ส่วนอื่นของโต๊ะไม่มีใครแตะ · รีเฟรชหน้าแล้วทุกจุดตรงกัน · เกณฑ์ "ส่วนอื่นไม่ขยับ" หมายถึงสิ่งนี้เลย

ข้อ 7 กับข้อความ 422: ถ้าคุณลองกดส่งฟอร์มคอมเมนต์ทั้งที่ว่าง Console จะขึ้นบรรทัดนี้ (ผลจริง):

Failed to load resource: the server responded with a status of 422 (Unprocessable Content)

บรรทัดนี้ปกติ — มันแค่รายงานว่า server ตอบ 422 ("ข้อมูลไม่ผ่าน" ที่เราตั้งใจส่งกลับเพื่อโชว์ error ในฟอร์ม · บท 132) · ที่เกณฑ์ข้อ 7 ห้ามคือข้อความที่มีคำว่า turbo-frame (หน้าตาของมันอยู่ในขั้น 3)

ข้อ 5 แบบเห็นกับตา: ใส่ console.log ชั่วคราว

grep บอกได้แค่ว่า "มีคำว่า disconnect ในไฟล์" · อยากเห็นว่ามันถูกเรียกจริง ให้เปิด app/javascript/controllers/character_counter_controller.js แล้วเติม 2 บรรทัดที่มีป้าย // ← เติมชั่วคราว (ที่เหลือคือไฟล์เดิมจากบท 134 ไม่ต้องแตะ):

import { Controller } from "@hotwired/stimulus"

// ตัวนับอักษรของช่องพิมพ์คอมเมนต์ (อยู่ใน comments/_form.html.erb) แสดงเป็น "12 / 1000"
export default class extends Controller {
  static targets = ["input", "output"]

  // ฟอร์มโผล่ (โหลดหน้า หรือ Turbo Stream ใส่ฟอร์มใหม่): เริ่มฟังการพิมพ์
  connect() {
    console.log("ตัวนับ: connect")        // ← เติมชั่วคราว
    this.onInput = this.update.bind(this)
    this.inputTarget.addEventListener("input", this.onInput)
    this.update()
  }

  // ฟอร์มหายไป: ถอด listener ที่ connect ผูกไว้
  disconnect() {
    console.log("ตัวนับ: disconnect")     // ← เติมชั่วคราว
    this.inputTarget.removeEventListener("input", this.onInput)
  }

  update() {
    this.outputTarget.textContent = `${this.inputTarget.value.length} / ${this.inputTarget.maxLength}`
  }
}

console.log(...) = เขียนข้อความลง Console (เหมือน fmt.Println ของ Go แต่ไปขึ้นในเบราว์เซอร์) · ไม่ต้อง restart server — importmap ส่งไฟล์ใหม่ให้เอง (บท 127)

บันทึกไฟล์ รีเฟรชหน้า · ส่งคอมเมนต์หนึ่งครั้ง แล้วกดส่งแบบว่างอีกครั้ง · Console ที่ได้จริง:

// เปิดหน้า
ตัวนับ: connect
// ส่งคอมเมนต์สำเร็จ (Turbo Stream เปลี่ยนฟอร์มเป็นฟอร์มว่าง)
ตัวนับ: disconnect
ตัวนับ: connect
// ส่งแบบว่าง (422 · Turbo Stream เปลี่ยนฟอร์มเป็นฟอร์มที่มี error)
Failed to load resource: the server responded with a status of 422 (Unprocessable Content)
ตัวนับ: disconnect
ตัวนับ: connect

ทุกครั้งที่ Turbo Stream เอาฟอร์มเก่าออก ตัวนับตัวเก่าเก็บกวาด (disconnect) แล้วตัวนับของฟอร์มใหม่ติดตั้ง (connect) เอง — โดยที่เราไม่ได้เขียนโค้ดสั่งเลยสักบรรทัด · ดูเสร็จแล้วคืนไฟล์ด้วย git restore app/javascript (คืนเฉพาะโฟลเดอร์ app/javascript ให้เหมือน commit ล่าสุด)

ข้อ 6 ดูให้ลึกกว่าตา: server ส่งอะไรมาจริง

ข้อนี้ตรวจว่าเราไม่ได้ทำ "หน้า edit ที่ใช้ได้แค่ในกรอบ" · curl (โปรแกรมที่ขอหน้าเว็บแบบไม่มีเบราว์เซอร์) จำลองการเปิดตรง ๆ ได้ (ผลจริง):

$ curl -s localhost:3000/posts/1/edit | grep -E '<title>|<h1|class="navbar'
    <title>แก้ไขบทความ</title>
--><header class="navbar bg-base-100 shadow-sm">
  <h1 class="font-bold text-4xl">แก้ไขบทความ</h1>

ครบทั้ง title · navbar · หัวเรื่อง = หน้าเต็ม (--> หน้าบรรทัด navbar คือปลายคอมเมนต์ที่ Rails แปะบอกว่ามาจาก partial ไหน · มีแค่ใน development) · ทีนี้ดูใน log/development.log เทียบกับตอนกด "แก้ไขหัวข้อ" ในหน้า show (ผลจริง ตัดมาบรรทัดเดียวต่อครั้ง):

# เปิด /posts/1/edit ตรง ๆ
  Rendering posts/edit.html.erb within layouts/application
# กด "แก้ไขหัวข้อ" ในกรอบ (เบราว์เซอร์แนบ header Turbo-Frame: title_post_1 มาด้วย)
  Rendering posts/edit.html+turbo_frame.erb within layouts/turbo_rails/frame

header = ป้ายข้อมูลที่แปะบนหัวใบสั่ง (ผู้ใช้มองไม่เห็น) · URL เดียวกัน แต่ server เลือกไฟล์ต่างกันตามว่าคำขอ "มาจากกรอบ" หรือไม่ — นี่คือ variant ที่ตั้งไว้ในบท 131 (set_turbo_frame_variant) · คนที่ปิด JavaScript หรือเปิดลิงก์ในแท็บใหม่ ยังแก้บทความได้ครบ — แนวคิดนี้ชื่อ progressive enhancement (ของพื้นฐานใช้ได้เสมอ JS แค่ทำให้ดีขึ้น)

🗣 ศัพท์ของขั้นนี้
DevTools · Console
เครื่องมือนักพัฒนาที่ติดมากับเบราว์เซอร์ · Console = แท็บที่โชว์ข้อความจาก console.log และ error ของ JavaScript · ถาม senior ได้ว่า "หน้านี้ไม่ขยับครับ แต่ Console มี error ตัวแดงข้อความนี้ — ช่วยดูหน่อย" (แล้วก็อปข้อความไปด้วยทั้งบรรทัด)
end-to-end check · two-browser test
ตรวจตั้งแต่ต้นทางถึงปลายทางแบบที่ผู้ใช้เจอจริง · ทดสอบ real-time ด้วยสองหน้าต่างพร้อมกัน · พิมพ์บอก AI ได้ว่า "I will verify the broadcast with two browser windows side by side. List what I should see in each window."
progressive enhancement
ทำให้หน้าใช้ได้ด้วย HTML ธรรมดาก่อน แล้วค่อยเติม JS ให้ลื่นขึ้น · ถ้า JS ไม่ทำงาน ผู้ใช้ยังทำงานได้ · ถาม senior ได้ว่า "ถ้าผู้ใช้เปิดลิงก์ edit ในแท็บใหม่ หน้านี้ยังทำงานครบไหมครับ"

ขั้น 3 — ตรวจ 4 อย่างด้วยคำสั่ง (สไลด์หน้า 53) และกับดักในคำสั่งของสไลด์

สไลด์หน้า 53 มีกล่อง "ตรวจ 4 อย่างนี้ก่อน":

  1. Console ของเบราว์เซอร์มี error ไหม — ทำแล้วในขั้น 2
  2. grep หาแท็ก <script ในโฟลเดอร์ view
  3. grep หาคำว่า disconnect ในโฟลเดอร์ JavaScript
  4. เปิด 2 แท็บทดสอบของจริง — ทำแล้วในขั้น 2

เหลือข้อ 2 กับ 3 ที่เป็นคำสั่ง · ข้อ 2 (ผลจริง):

$ grep -rn "<script" app/views
$

ทวนจากบท 126: grep = ค้นหาข้อความในไฟล์ · -r ค้นทั้งโฟลเดอร์ · -n บอกเลขบรรทัด · ผลจริงคือไม่มีอะไรขึ้นเลย = ไม่เจอ = ผ่าน (ไม่มีแท็ก <script ซ่อนอยู่ใน view ไหน) · ส่วนข้อ 3 สไลด์เขียนคำสั่งแบบนี้:

$ grep -n "disconnect" app/javascript/**

ไฟล์ตัวนับมี disconnect() { อยู่บรรทัด 15 ของ app/javascript/controllers/character_counter_controller.js แน่ ๆ — คำสั่งนี้จะเจอไหม? ขึ้นอะไร?

ไม่เจอ · ผลจริง (ลองทั้ง zsh ที่เป็นค่าตั้งต้นของ terminal บน Mac และ bash ได้เหมือนกัน):

$ grep -n "disconnect" app/javascript/**
grep: app/javascript/controllers: Is a directory
  • terminal (ตัวที่อ่านคำสั่งเรียกว่า shell) แปลง ** เป็นรายชื่อไฟล์ก่อนส่งให้ grep · ** ที่ไม่มีอะไรต่อท้าย มันอ่านเหมือน * ธรรมดา = "ของที่อยู่ชั้นแรกของโฟลเดอร์" → ได้แค่ app/javascript/application.js กับโฟลเดอร์ controllers
  • grep ไม่มี -r จึงไม่เข้าไปในโฟลเดอร์ บ่นว่า "Is a directory" แล้วข้ามไป · ไฟล์ตัวนับอยู่ในโฟลเดอร์นั้น จึงไม่ถูกค้นเลย
  • อันตรายตรงที่ข้อความนี้ดูเหมือน "ไม่เจอ disconnect" — คุณอาจสรุปผิดว่างานไม่ผ่าน (หรือแย่กว่า: ใช้คำสั่งนี้ตรวจงานที่ไม่มี disconnect จริง แล้วไม่ทันสังเกตว่ามันไม่ได้ค้นอะไรเลย)

คำสั่งที่ใช้ได้จริง — เติม -r แล้วให้ชื่อโฟลเดอร์ตรง ๆ (ผลจริง):

$ grep -rn "disconnect" app/javascript
app/javascript/controllers/character_counter_controller.js:15:  disconnect() {

อ่านผล: ชื่อไฟล์:เลขบรรทัด:เนื้อบรรทัด · theme_controller.js กับ hello_controller.js ไม่มี disconnect และไม่ต้องมี เพราะไม่ได้สร้าง listener หรือตัวจับเวลาใน connect (กฎข้อ 10 พูดถึงเฉพาะ controller "ที่สร้างของใน connect")

ใส่บั๊กกลับ ก่อนเชื่อว่า "ไม่เจอ = ผ่าน"

ตัวตรวจที่ไม่เคยจับอะไรได้เลย อาจแปลว่างานดี — หรือแปลว่าตัวตรวจพัง (เหมือนคำสั่ง ** ข้างบน) · ทางเดียวที่จะรู้คือใส่บั๊กให้มันจับดูก่อน:

① เติม 3 บรรทัดนี้ท้าย app/views/posts/show.html.erb ชั่วคราว (แบบที่ AI ชอบเขียน):

<script>
  document.querySelector("#comment_body").addEventListener("input", (e) => console.log("script นับได้", e.target.value.length))
</script>

ไม่ต้องจำโค้ดนี้ — แค่จำหน้าตา "แบบที่ AI ชอบเขียน" ไว้ · บันทึกไฟล์แล้วรัน grep เดิม (ผลจริง):

$ grep -rn "<script" app/views
app/views/posts/show.html.erb:38:<script>

จับได้ ✔ · เลข 38 = บรรทัดที่เพิ่งเติม (ของคุณอาจต่างถ้าไฟล์ยาวไม่เท่า) · ② ลบ 3 บรรทัดนั้นออก แล้วลบเมธอด disconnect() ทั้งก้อนออกจากไฟล์ตัวนับชั่วคราว:

$ grep -rn "disconnect" app/javascript
$

ไม่มีผล = จับได้ว่าหายไป ✔ · คืนของทั้งหมดด้วย git restore app แล้ว git status --short ต้องไม่เห็นไฟล์ใต้ app/ เลย

⚠️ ติดนิสัยคืนเฉพาะโฟลเดอร์ที่ทดลอง (git restore app) ไม่ใช่ git restore . — คำสั่งหลังคืนทุกไฟล์ที่แก้แต่ยังไม่ commit รวมถึงงานจริงที่ยังไม่ได้เก็บ เช่น prompts/00-context.md ที่คุณจะเติมในขั้น 4 · และมันทิ้งโดยไม่ถามสักคำ

🍽 เทียบให้เห็นภาพ — กดปุ่มทดสอบเครื่องตรวจควันในครัว

เครื่องตรวจควันที่เงียบมาทั้งปี อาจเพราะครัวปลอดภัย หรือเพราะถ่านหมด · วิธีเดียวที่จะรู้คือลองให้มีควัน (กดปุ่มทดสอบ) แล้วฟังว่ามันร้องไหม · ใส่ <script> ปลอม ๆ หรือลบ disconnect ชั่วคราว = ปล่อยควันให้ตัวตรวจได้ลองร้อง

ที่ไม่เหมือนกัน: เครื่องตรวจควันจับควันได้ทุกแบบ · grep จับได้แค่ตัวอักษรที่สั่งให้หาเป๊ะ ๆ — AI ที่เขียน <SCRIPT> ตัวใหญ่ (เติม -i ให้ grep ไม่สนตัวเล็กตัวใหญ่ได้) หรือผูก listener ผ่าน library อื่น จะรอด grep ไปได้ · grep จึงเป็น "ด่านแรกที่เร็ว" ไม่ใช่ด่านสุดท้าย

🗣 ศัพท์ของขั้นนี้
glob · * · **
glob = รูปแบบชื่อไฟล์ที่ shell ขยายให้ก่อนรันคำสั่ง · * = ชื่ออะไรก็ได้ในชั้นเดียว · **/*.js = ทุกไฟล์ .js ทุกชั้น (ต้องมี /*.js ต่อท้าย) · พิมพ์บอก AI ได้ว่า "Use grep -rn on the folder instead of a ** glob — the glob didn't recurse in my shell"
false negative · sanity check
false negative = ตัวตรวจบอกว่า "ไม่มีปัญหา" ทั้งที่มี · sanity check = ลองให้ตัวตรวจจับของที่รู้คำตอบอยู่แล้ว ก่อนเชื่อผลของมัน · ถาม senior ได้ว่า "check ตัวนี้เคยจับบั๊กได้จริงไหมครับ หรือผ่านมาตลอด"

ที่ AI พลาดประจำในบล็อกนี้ — อาการ · ตรวจยังไง · แก้ยังไง

สไลด์หน้า 53 รวมไว้ 6 ข้อ · ผมใส่บั๊กแต่ละแบบเข้าไปในโค้ดที่ทำงานถูกแล้วทีละข้อ แล้วดูอาการจริงในเบราว์เซอร์ · สังเกตว่าหลายข้อหน้าเว็บไม่โชว์ error อะไรเลย:

AI ทำแบบนี้อาการที่เห็นจริงตรวจยังไงแก้ยังไง
frame id ไม่ตรงกันสองฝั่ง (ฝั่งหนึ่งพิมพ์ "post_title" เอง อีกฝั่งใช้ dom_id) กด "แก้ไขหัวข้อ" แล้วหัวข้อหายกลายเป็นคำว่า Content missing · Console มี error ตัวแดง (ข้อความอยู่ใต้ตาราง) grep -rn "turbo_frame_tag" app/views → ทุกบรรทัดต้องเป็น dom_id(…) ใช้ dom_id(post, :title) ทั้งสองฝั่ง (บท 131)
ลืม status: :unprocessable_content ตอน render ฟอร์มใหม่ กดบันทึกฟอร์มที่ข้อมูลผิด แล้วหน้าไม่เปลี่ยนเลย ไม่มีข้อความ error · log บอก Completed 200 OK · Console: Error: Form responses must redirect to another location grep -rn "render :new\|render :edit" app/controllers → ทุกบรรทัดต้องมี status: เติม status: :unprocessable_content (ทวนบท 132)
เขียน JS แบบ querySelector ใน <script> แทน Stimulus ตอนเปิดหน้าใช้ได้ · แต่หลังส่งคอมเมนต์ครั้งแรก มันเงียบ (ฟอร์มใหม่ที่ Turbo Stream ใส่มาไม่มีใครผูก listener ให้) — ส่วนตัวนับ Stimulus ยังนับต่อ grep -rn "<script" app/views ย้ายไปเป็น Stimulus controller (บท 134)
Stimulus controller ไม่มี disconnect ส่วนใหญ่ไม่เห็นอาการทันที · listener ซ้อนกันเมื่อ element เดิมถูกถอดแล้วใส่ใหม่ หรือรั่วสะสมถ้าผูกกับ window / ตัวจับเวลา (บท 134) grep -rn "disconnect" app/javascript ต้องเจอในทุกไฟล์ที่มี addEventListener หรือ setInterval (ตัวจับเวลาที่ทำซ้ำทุก ๆ กี่วินาที) ใน connect เขียน disconnect() ที่ถอดของที่ connect() สร้าง
ใช้ after_save แทน after_commit ส่วนใหญ่ใช้ได้ตอนลองคนเดียว · ปัญหาโผล่เมื่อ job ทำงานก่อนข้อมูลลงตู้จริง แล้วไปหยิบแฟ้มที่ยังไม่มี (บท 133 · ไม่ได้รันกรณีนี้ให้ดู) grep -rn "after_" app/models → ทุกตัวที่ broadcast ต้องลงท้าย _commit ใช้ broadcasts_to (ข้างในเป็น after_commit อยู่แล้ว) หรือ after_create_commit
ลืม turbo_stream_from ในหน้า show หน้าต่างที่ส่งเองเห็นคอมเมนต์ (จาก Turbo Stream ตอบฟอร์ม) · อีกหน้าต่างนิ่ง ตัวนับยัง 0 · Console ว่าง · log ยังเขียนว่า Broadcasting to … เหมือนปกติ ทดสอบสองหน้าต่าง · grep -rn "turbo_stream_from" app/views เติม <%= turbo_stream_from @post %> ในหน้า show (บท 133)

อ่านคำสั่งในคอลัมน์ "ตรวจยังไง": \| ใน grep แปลว่า "หรือ" — "render :new\|render :edit" = หาบรรทัดที่มี render :new หรือ render :edit

ข้อความ error ของแถวแรก (ผลจริง · เลข title_post_2 คือบทความที่ผมลอง · ใน Console จะมีคำนำหน้าสั้น ๆ และบรรทัดชื่อไฟล์ turbo.min-….js ตามมาอีกหลายบรรทัด — อ่านแค่ประโยคนี้พอ):

The response (200) did not contain the expected <turbo-frame id="title_post_2"> and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.

แปลภาษาคน: "ครัวส่งจานกลับมา แต่ในจานไม่มีถ้วยที่ติดป้าย title_post_2 — พนักงานเลยไม่รู้จะเอาอะไรไปวาง" · นี่คือ "error เรื่อง matching turbo-frame" ที่เกณฑ์ข้อ 7 พูดถึง

และ log ของแถวที่สอง (ผลจริง · ตัดมาสองบรรทัด · ::1 = เครื่องตัวเอง แบบเดียวกับ localhost) — server ตอบ "200 สำเร็จ" ทั้งที่บันทึกไม่ได้:

Started POST "/posts" for ::1 at 2026-11-07 11:00:28 +0700
Completed 200 OK in 12ms (Views: 6.0ms | ActiveRecord: 2.2ms (1 query, 0 cached) | GC: 0.2ms)

ทำไมหน้าไม่เปลี่ยน: Turbo ถือกฎว่า "ส่งฟอร์มสำเร็จ (200) ต้องตามด้วยการพาไปหน้าอื่น (redirect)" · พอได้ 200 พร้อมหน้าฟอร์มเดิมกลับมา มันเลยไม่วาด แล้วบ่นใน Console · พอใส่ 422 ("ข้อมูลไม่ผ่าน") Turbo รู้ว่า "อ๋อ ให้วาดฟอร์มที่มี error" → ผู้ใช้เห็นข้อความ บันทึกไม่ได้ มี 3 จุดที่ต้องแก้ ตามปกติ (ผลจริงตอนมี status)

⚠️ แถวสุดท้ายน่ากลัวที่สุด — ไม่มีใครฟ้องเลย ทั้ง Console ทั้ง log · ถ้าตรวจด้วยหน้าต่างเดียวจะ "ผ่าน" ทุกข้อ · นี่คือเหตุผลที่สไลด์ยกเกณฑ์สองหน้าต่างเป็นเกณฑ์ที่ดีที่สุด

💬 อาการไม่ตรงกับตารางนี้? ส่งสามอย่างไปถาม senior หรือ AI: ข้อความใน Console ทั้งบรรทัด · log ของ request นั้น (จาก Started ถึง Completed) · และไฟล์ที่เกี่ยวกับเกณฑ์ข้อนั้น (คอลัมน์ "ไฟล์หลัก" ในตารางต้นบท)

บอกด้วยว่าเปิดกี่หน้าต่าง ทำอะไรในหน้าต่างไหน — ปัญหา real-time มักซ่อนอยู่ในคำตอบข้อนี้

ขั้น 4 — เติมกฎ 9–12 ลง 00-context.md: คู่มือโตจาก 8 ข้อเป็น 12 ข้อ

ทวนบท 121: prompts/00-context.md คือกฎถาวรของร้าน · และ AGENTS.md (ซึ่ง CLAUDE.md ชี้ไปหา) มีบรรทัด @prompts/00-context.md → Claude Code อ่านกฎทุกข้อเองทุกครั้งที่เปิดในโปรเจกต์นี้ · สไลด์หน้า 53 ให้เติม 4 ข้อจากบล็อก Hotwire

เปิด prompts/00-context.md · หาบรรทัด 8. strong parameters ไม่ใช่ authorization — แยกให้ออก · ใต้บรรทัดว่างที่ต่อจากข้อ 8 พิมพ์ก้อนนี้ แล้วเว้นบรรทัดว่างอีกหนึ่งบรรทัดก่อนหัวข้อ ## รูปแบบการตอบ:

## กฎเพิ่มจากบล็อค Hotwire

9. JS ทั้งหมดอยู่ใน Stimulus controller
   ห้ามมี `<script>` ใน view
10. ทุก controller ที่สร้างของใน connect
    ต้องเก็บกวาดใน disconnect
11. turbo-frame id ใช้ dom_id() ทั้งสองฝั่ง
12. render ใหม่หลัง validation ไม่ผ่าน
    ต้องมี status: :unprocessable_content

บันทึกแล้วตรวจด้วย git diff prompts/00-context.md — ควรได้แบบนี้ (ผลจริง ตัดหัว 4 บรรทัดแรกออก) · บรรทัด @@ … @@ = git บอกว่าแก้ตรงบรรทัดที่เท่าไร · บรรทัดที่ขึ้นต้นด้วย + คือที่คุณเติม · บรรทัดที่ขึ้นต้นด้วยช่องว่างคือของเดิมรอบ ๆ:

@@ -28,6 +28,16 @@
 7. migration ต้องไม่ล็อกตารางที่มีข้อมูลเยอะ — ใช้ expand-contract
 8. strong parameters ไม่ใช่ authorization — แยกให้ออก

+## กฎเพิ่มจากบล็อค Hotwire
+
+9. JS ทั้งหมดอยู่ใน Stimulus controller
+   ห้ามมี `<script>` ใน view
+10. ทุก controller ที่สร้างของใน connect
+    ต้องเก็บกวาดใน disconnect
+11. turbo-frame id ใช้ dom_id() ทั้งสองฝั่ง
+12. render ใหม่หลัง validation ไม่ผ่าน
+    ต้องมี status: :unprocessable_content
+
 ## รูปแบบการตอบ

ถ้าคุณทำภารกิจท้ายบท 126 ไว้ (หัวข้อ "กฎที่ทีมเราเจอเอง" ท้ายไฟล์) ก็ไม่ชนกัน — อันนั้นอยู่ท้ายไฟล์ อันนี้อยู่ต่อจากข้อ 8 · ถ้อยคำตรงกับสไลด์ทุกคำ ต่างแค่ใส่ backtick รอบ <script> ให้เหมือนบรรทัด "ห้ามมี <script> ใน view" ที่มีอยู่แล้วในไฟล์

กฎมาจากที่ AI พลาดข้อไหนตรวจด้วยคำสั่ง
9 · JS อยู่ใน Stimulus · ห้าม <script>querySelector ใน <script>grep -rn "<script" app/views ต้องว่าง
10 · สร้างของใน connect ต้องเก็บใน disconnectไม่มี disconnectgrep -rn "disconnect" app/javascript
11 · turbo-frame id ใช้ dom_id() ทั้งสองฝั่งframe id ไม่ตรงกันgrep -rn "turbo_frame_tag" app/views
12 · render หลัง validation ไม่ผ่านต้องมี statusลืม statusgrep -rn "render :new\|render :edit" app/controllers

นับดู: ไฟล์นี้มีกฎ 8 ข้อมาตั้งแต่เช้า (บล็อก A กับ B ของเราไม่ได้เพิ่มกฎลงไฟล์นี้ — บท 126 ให้เติมเป็นภารกิจเสริมแยกหัวข้อ) · ตอนนี้มี 12 ข้อ — ตรงกับที่สไลด์หน้า 54 เขียนว่า "00-context.md ที่โตจาก 8 กฎเป็น 12 กฎ" · ทุกข้อเกิดจากความผิดพลาดที่เคยเกิดจริง ไม่ได้คิดขึ้นลอย ๆ

🍽 เทียบให้เห็นภาพ — แฟ้มกฎถาวรข้างป้ายหน้าห้องพนักงาน ที่หนาขึ้นทุกครั้งที่มีเรื่อง

ทวนภาพจากบท 121: CLAUDE.md คือป้ายหน้าห้องพนักงาน · 00-context.md คือแฟ้มกฎถาวรที่แขวนข้างป้าย · ร้านเปิดใหม่แฟ้มนี้มีกฎไม่กี่ข้อ · วันหนึ่งพนักงานใหม่ลืมปิดแก๊ส → เติมข้อ "ปิดแก๊สก่อนกลับ" · อีกวันเสิร์ฟผิดโต๊ะ → เติมข้อ "เช็คเลขโต๊ะสองครั้ง" · แฟ้มที่หนาขึ้นคือบันทึกบทเรียนของร้าน · พนักงานใหม่ (AI) ทุกกะอ่านป้ายและแฟ้มข้างป้ายก่อนเข้างาน จึงไม่ต้องพลาดเรื่องเดิมซ้ำ

ที่ไม่เหมือนกัน: พนักงานคนจริงทำผิดกฎแล้วโดนเตือน · AI อ่านกฎแล้วยังพลาดได้ โดยไม่มีใครจับได้ทันที — กฎใน context เป็นคำแนะนำ ไม่ใช่การบังคับ (บท 121) · คอลัมน์ "ตรวจด้วยคำสั่ง" ในตารางข้างบนจึงสำคัญเท่ากับตัวกฎ

🗣 ศัพท์ของขั้นนี้
project memory · rules file
ไฟล์ที่เครื่องมือ AI โหลดเข้า context ให้เองทุกครั้ง เช่น CLAUDE.md / AGENTS.md และไฟล์ที่มัน @ ไปถึง · พิมพ์บอก AI ได้ว่า "Follow the rules in prompts/00-context.md, especially 9–12. Point out any rule you had to break and why."
guardrail
กฎหรือการตรวจที่กันไม่ให้ผิดซ้ำ · กฎที่ดีบอกได้ว่า "ทำอะไรแทน" และตรวจได้ด้วยคำสั่ง · ถาม senior ได้ว่า "กฎข้อนี้ทีมมี check อัตโนมัติไหมครับ หรือต้องตรวจตอน review เอง"

ขั้น 5 — เก็บเข้าโปรเจกต์

โค้ดแอปของบล็อก C คุณ commit ไปแล้วทีละบท (บท 131–134) · ของใหม่ของบทนี้มีแค่สองไฟล์ใน prompts/ · ดูก่อนว่าจะเก็บอะไร (ผลจริง — ต้องมีแค่สองบรรทัดนี้):

$ git status --short
 M prompts/00-context.md
?? prompts/03-hotwire.md

M = แก้ไฟล์เดิม (เติมกฎ 9–12) · ?? = ไฟล์ใหม่ที่ git ยังไม่รู้จัก · ถ้ามีไฟล์ใต้ app/ โผล่มาด้วย แปลว่ายังมีของทดลองค้างจากขั้น 2–3 (เช่น console.log ที่ลืมลบ) → git diff app ดูก่อน แล้ว git restore app

$ git add .
$ git commit -m "prompts: ใบสั่งงาน Hotwire + กฎ 9–12"
[my-day3 cac01ee] prompts: ใบสั่งงาน Hotwire + กฎ 9–12
 2 files changed, 47 insertions(+)
 create mode 100644 prompts/03-hotwire.md

create mode 100644 = git บอกว่าเป็นไฟล์ใหม่ (เลข 100644 คือ "ไฟล์ธรรมดา" ไม่ต้องสนใจ) · my-day3 = branch ที่สร้างไว้ในบท 120 · เลข cac01ee ของคุณจะต่าง · 47 insertions = 10 บรรทัดใน 00-context + 37 บรรทัดของไฟล์ใหม่ · สไลด์หน้า 53 ให้ commit ทั้งบล็อกเป็นก้อนเดียวด้วยข้อความ "hotwire: inline edit + live comments" — ของเราแบ่งเป็น 5 ก้อนตามบท จึงย้อนดูทีละขั้นได้ (ผลจริง):

$ git log --oneline -6
cac01ee prompts: ใบสั่งงาน Hotwire + กฎ 9–12
ddb4521 Stimulus ตัวนับอักษรช่องคอมเมนต์
184f5c7 hotwire: broadcast คอมเมนต์ทุกแท็บ
16cad7a Comment + ฟอร์มคอมเมนต์ตอบด้วย Turbo Stream
58001e9 แก้หัวข้อบทความในหน้าเดิมด้วย Turbo Frame
faebd6f styling: daisyUI + navbar + ปุ่มสลับธีม

อ่านจากล่างขึ้นบน = ลำดับที่ทำ · บรรทัดล่างสุดคือจบ Lab Vibe 2 (บท 129) · ข้อความ commit ของคุณต้องตรงกับที่แต่ละบทให้พิมพ์ (ถ้าเคย commit ภารกิจเสริมเพิ่ม จะมีบรรทัดแทรกอยู่ — ไม่เป็นไร)

✅ จบ Lab: โค้ดของคุณตอนนี้คือจุดเช็คพอยต์ C — จบ Day 3 ช่วงเช้า · สไลด์หน้า 53 บอกว่าตามไม่ทันให้ git checkout day3-c-done (branch สำเร็จรูปของอาจารย์) — ใช้ได้ แต่ไฟล์ข้างในต่างจากบทเรียนนี้มาก (ใช้ระบบแปลภาษา t(".title") + gem rails-i18n · ชื่อ id ฟอร์มต่างกัน · กฎใน 00-context เลขข้อต่างกัน) · แนะนำให้ไล่บท 131–134 มากกว่า

ทางเลือก: ลอง vibe จริง — ให้ AI ทำบล็อก C ใหม่ทั้งหมด แล้วตรวจด้วย checklist ชุดนี้

ทำหลัง commit ข้างบนแล้ว · ไม่บังคับ · นี่คือสิ่งที่สไลด์ตั้งใจให้ทำใน Lab: ยิงใบสั่งงานให้ AI แล้วเราเป็นคนตรวจ · บทนี้ไม่โชว์ว่า AI ตอบอะไร (ของแต่ละคนจะต่างกันทุกครั้ง) — ให้คุณดูเองแล้วตรวจด้วยตาราง "AI พลาดประจำ"

  1. git log --oneline -6 → หาเลข commit ของ Lab Vibe 2 (styling: daisyUI + navbar + ปุ่มสลับธีม — บรรทัดใต้ แก้หัวข้อบทความในหน้าเดิมด้วย Turbo Frame) · ในตัวอย่างข้างบนคือ faebd6f (ของคุณจะเป็นเลขอื่น)
  2. git switch -c ai-vibe3 <เลข commit นั้น> — สร้าง branch ใหม่จากจุดจบบท 129 แล้วย้ายไปอยู่ (โค้ดใน editor จะไม่มีคอมเมนต์ ไม่มีตัวนับ)
  3. git checkout @{-1} -- prompts/ — ก็อปโฟลเดอร์ prompts/ จาก branch ที่เพิ่งจากมา (@{-1} = "branch ก่อนหน้า") · AI จะได้ทั้งใบสั่ง 03-hotwire.md และกฎ 9–12
  4. ปิด bin/dev (Ctrl+C) แล้ว bin/rails db:reset แล้วเปิด bin/dev ใหม่ — db:reset ล้างตู้แล้วสร้างใหม่จาก db/schema.rb ของ branch นี้ + seeds (คอมเมนต์ทดลองหายหมด) · ข้ามไม่ได้ เหตุผลในกล่องเตือนข้างล่าง
  5. เปิด Claude Code (บท 86) แล้วสั่งประมาณ: "ทำตาม @prompts/03-hotwire.md ทีละเป้าหมาย · เสร็จแต่ละเป้าหมายให้บอกว่าแก้ไฟล์ไหน และบอกวิธีตรวจเกณฑ์ที่เกี่ยวข้อง" (00-context.md ถูกโหลดให้เองผ่าน CLAUDE.md ไม่ต้อง @ ซ้ำ)
  6. ตรวจเองครบทั้ง 7 ข้อ + grep ทุกตัว — อย่าเชื่อที่ AI บอกว่าผ่าน · จดว่า AI พลาดข้อไหน
  7. กลับบ้าน: git add . && git commit -m "ลอง AI บล็อก C" → git switch my-day3 → ปิด bin/dev แล้ว bin/rails db:reset อีกรอบ (ให้ตู้กลับมีตาราง comments แบบของเรา) แล้วเปิด bin/dev ใหม่

⚠️ ถ้าข้ามขั้น 4 (db:reset) ตู้ของคุณยังมีตาราง comments จากบท 132 อยู่ · พอ AI สร้าง migration ของมันแล้วสั่ง migrate จะพังแบบนี้ (ผลจริง ตัดมาบางบรรทัด):

bin/rails aborted!
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)

PG::DuplicateTable: ERROR:  relation "comments" already exists

"ตาราง comments มีอยู่แล้ว" — สลับ branch เปลี่ยนแค่ไฟล์ ไม่ได้เปลี่ยนของในตู้ (บท 120) · ถ้า AI เสนอให้ลบ migration หรือแก้ schema.rb เพื่อเลี่ยง error นี้ — ไม่ต้องตาม ตัวปัญหาคือตู้ ไม่ใช่โค้ด · ใช้ db:reset แทน

สรุป Day 3 ช่วงเช้า — เช้านี้ได้อะไรไปบ้าง

สไลด์หน้า 54 สรุปไว้ประโยคเดียว: ตอนเช้าเรามีหน้า scaffold เปล่า ๆ ตอนนี้เรามีเว็บที่ใช้งานได้จริงแล้ว

บล็อกหน้าเว็บได้อะไรเข้าใจอะไรใบสั่งงาน
A · View layer (บท 120–126)หน้า index + show ที่อ่านรู้เรื่อง · แบ่งหน้า · ทางกลับERB · partial + locals ที่ใช้ซ้ำได้ · helper · XSS · N+1 ใน view · seed 30 บทความ 5 หมวดที่รันซ้ำได้prompts/01-views.md
B · CSS + assets (บท 127–129)navbar + card ด้วย daisyUI · สลับธีมสว่าง/มืดfingerprinting · Propshaft · importmap · Tailwind v4 · กับดักชื่อคลาสที่ต่อ stringprompts/02-styling.md
C · Hotwire (บท 130–135)แก้หัวข้อในหน้าเดิม · คอมเมนต์เด้งทุกแท็บ · ตัวนับอักษรTurbo Drive · Frame · Stream · broadcast · Stimulus connect/disconnectprompts/03-hotwire.md

ตอนนี้ prompts/ มี 4 ไฟล์ (ผลจริง · จำนวนบรรทัดของคุณอาจต่างเล็กน้อยถ้าเติมกฎเองไว้):

$ wc -l prompts/*
      45 prompts/00-context.md
      33 prompts/01-views.md
      30 prompts/02-styling.md
      37 prompts/03-hotwire.md
     145 total

wc -l = นับจำนวนบรรทัดของแต่ละไฟล์ · 00-context.md = กฎถาวร 12 ข้อ · อีกสามไฟล์ = ใบสั่งงานของสามบล็อก · สไลด์บอกว่าช่วงบ่ายจะได้อีก 3 ไฟล์ และวันถัดไปอีก 2 ไฟล์ ก่อนเอามารวมกัน

และประโยคหลัก 3 ข้อของวัน (สไลด์หน้า 8) — เช้านี้เจอไปแล้วในรูปไหน:

ควรเข้าใจอะไรแล้วบ้าง — เช็คตัวเองทีละบท

ลองตอบในใจทีละข้อ · ข้อไหนตอบไม่ได้ กลับไปอ่านบทนั้นก่อนเข้าช่วงบ่าย (ไม่ต้องจำโค้ด แค่อธิบายเป็นภาษาคนได้ก็พอ)

บทถ้าตอบข้อนี้ได้ = เข้าใจแล้ว
120ทำไม seeds ต้องรันซ้ำได้ และทำไมต้องมีข้อมูลถึง 30 แถว
121prompt 4 ส่วนมีอะไร · CLAUDE.md กับ 00-context.md ต่างกันยังไง · ทำไมกฎใน context "ไม่ใช่การบังคับ"
122ทำไม Rails ไม่ต้องมีชั้น API · <%= %> กับ <% %> ต่างกันยังไง และลืม = แล้วเกิดอะไร
123partial ที่ใช้ locals ต่างจาก partial ที่แตะ @ตัวแปร ยังไง · content_for ส่งของไปไหน
124form_with model: รู้ได้ยังไงว่าต้อง POST หรือ PATCH · ของฟรีที่มันให้มีอะไรบ้าง
125ทำไม raw / html_safe อันตราย · นับ query ของหน้าหนึ่งจาก log ยังไง
126ตรวจรับงานด้วย checklist ยังไง · ทำไมต้องใส่บั๊กกลับก่อนเชื่อตัวตรวจ
127fingerprinting แก้ปัญหา cache ยังไง · importmap ทำให้ไม่ต้องมี node_modules ได้ยังไง
128ทำไมชื่อคลาส Tailwind ที่ต่อ string ตอนรันถึงไม่มีสี · semantic color ของ daisyUI ดีกว่าชื่อสีตรง ๆ ตรงไหน
129ธีมที่เลือกถูกจำไว้ที่ไหน · ทำไมต้องลอง assets:precompile ก่อนส่งงาน
130Turbo Drive ทำอะไรตอนกดลิงก์ · ทำไม JS ที่รอ DOMContentLoaded ทำงานแค่ครั้งแรก
131ทำไม id ของกรอบต้องตรงกันสองฝั่ง · เปิด /posts/1/edit ตรง ๆ แล้วทำไมยังได้หน้าเต็ม
132Frame กับ Stream ต่างกันยังไง · ทำไมตอนข้อมูลไม่ผ่านต้องตอบ 422
133broadcast ไปถึงอีกแท็บได้ยังไง · ทำไมต้อง after_commit · Turbo ไม่เหมาะกับงานแบบไหน
134ชื่อไฟล์ controller ผูกกับ data-controller ยังไง · connect / disconnect ถูกเรียกตอนไหน

ต่อจากนี้ — ทำให้มันเป็นผลิตภัณฑ์

ช่วงบ่ายของ Day 3 (สไลด์ชุดถัดไป · เว็บนี้จัดไว้เป็น Rails Day 4 · Part 1) มีอีกสามบล็อก:

เรื่องระบบล็อกอินกับการเขียน test อยู่ Day 4 · เช้านี้ช่องชื่อผู้เขียนคอมเมนต์จึงยังพิมพ์เองได้ (ข้อกำหนดเขียนไว้ว่า "ยังไม่มีระบบล็อกอิน")

ภารกิจ: รัน checklist ทั้งหมดกับโปรเจกต์ของคุณเอง

🎯 ภารกิจ Lab Vibe 3 (~10 นาที)

  1. git status --short ต้องว่าง (commit ขั้น 5 แล้ว) · แท็บ terminal ที่ 1 รัน bin/dev
  2. เปิด Chrome สองหน้าต่างวางข้างกัน ที่ http://localhost:3000/posts/2 (ลองบทความอื่นนอกจาก 1) · เปิด Console ทั้งสองหน้าต่าง
  3. ทำเกณฑ์ข้อ 1–4 ในตารางขั้น 2: แก้หัวข้อ → ส่งคอมเมนต์จากหน้าต่างซ้าย → ดูหน้าต่างขวา
  4. เปิดแท็บที่สาม พิมพ์ localhost:3000/posts/2/edit ตรง ๆ (ข้อ 6)
  5. รันใน terminal แท็บที่ 2 ทีละบรรทัด:
    grep -rn "<script" app/views
    grep -rn "disconnect" app/javascript
    grep -rn "turbo_frame_tag" app/views
    grep -rn "render :new\|render :edit" app/controllers
    grep -rn "after_" app/models
  6. ติ๊กเกณฑ์ทั้ง 7 ข้อใน prompts/03-hotwire.md ของคุณเอง (เปลี่ยน [ ] เป็น [x]) ถ้าผ่านจริง — แล้ว git restore prompts/03-hotwire.md ให้ใบสั่งกลับเป็นช่องว่างไว้ใช้ครั้งหน้า

ควรเห็น: คอมเมนต์โผล่ในหน้าต่างขวาเองภายในเสี้ยววินาที · ตัวนับ "ความคิดเห็น (…)" เพิ่มพร้อมกันทั้งสองฝั่ง · ฟอร์มซ้ายว่างและตัวนับอักษรเป็น 0 / 1000 · Console ไม่มีตัวแดง · grep บรรทัดแรกไม่มีผล · บรรทัดที่สองเจอ 1 บรรทัด · บรรทัดที่สามเจอ 2 บรรทัดที่เป็น dom_id ทั้งคู่

ถ้าเจออาการนี้ แปลว่า…

ผลจริงของคำสั่ง grep ทั้ง 5 บรรทัดบนโค้ดจุดเช็คพอยต์ C:

$ grep -rn "<script" app/views
$ grep -rn "disconnect" app/javascript
app/javascript/controllers/character_counter_controller.js:15:  disconnect() {
$ grep -rn "turbo_frame_tag" app/views
app/views/posts/edit.html+turbo_frame.erb:2:<%= turbo_frame_tag dom_id(@post, :title) do %>
app/views/posts/_post.html.erb:11:      <%= turbo_frame_tag dom_id(post, :title) do %>
$ grep -rn "render :new\|render :edit" app/controllers
app/controllers/categories_controller.rb:31:        format.html { render :new, status: :unprocessable_content }
app/controllers/categories_controller.rb:44:        format.html { render :edit, status: :unprocessable_content }
app/controllers/posts_controller.rb:34:        format.html { render :new, status: :unprocessable_content }
app/controllers/posts_controller.rb:47:        format.html { render :edit, status: :unprocessable_content }
$ grep -rn "after_" app/models
app/models/comment.rb:7:  after_create_commit -> { broadcast_update_later_to post, target: "comment_count", html: post.comments.count }
  • บรรทัดแรกไม่มีผล = ไม่มี <script> ใน view (กฎ 9) · บรรทัดที่สอง = ตัวนับมี disconnect (กฎ 10)
  • สองฝั่งของกรอบใช้ dom_id(…, :title) เหมือนกัน (กฎ 11) · ฝั่งหนึ่งเป็น @post (ในหน้า edit) อีกฝั่งเป็น post (local ใน partial) แต่ได้ id เดียวกัน title_post_2
  • ทุก render :new / render :edit มี status (กฎ 12) · ของ categories มาจาก scaffold ตั้งแต่ต้น · ฟอร์มคอมเมนต์ (บท 132) ใช้ render turbo_stream: … แยกบรรทัด จึงไม่ติด grep นี้ — เปิด comments_controller.rb ดูเองว่ามี status: :unprocessable_content ทั้งสองที่
  • บรรทัดสุดท้ายคือบรรทัดของบท 133 ที่ส่งตัวนับใหม่ไปทุกแท็บ · ชื่อลงท้าย _commit · broadcasts_to :post ไม่ขึ้นใน grep นี้เพราะไม่มีคำว่า after_ แต่ข้างในใช้ after_commit อยู่แล้ว (บท 133)
  • ใน Chrome ของผม: คอมเมนต์ถึงหน้าต่างขวาใน 104 ms · ทั้งสองฝั่งได้ (1) · ฟอร์มซ้ายว่าง · Console ว่างทุกหน้าต่าง · หน้า edit ตรง ๆ มี navbar + ฟอร์ม 3 ช่อง

Quiz

ข้อ 1 · (จากสไลด์หน้า 51) สถานการณ์ A: "เราเพิ่มคอมเมนต์ แล้วหน้าเราต้องอัปเดต 3 จุด" · สถานการณ์ B: "คนอื่นเพิ่มคอมเมนต์ แล้วหน้าเราขึ้นเอง" · ทั้งคู่ใช้ Turbo Stream — ต่างกันตรงไหน?

ตัวเลือกที่ 3 ถูก · A = Turbo Stream ตอบกลับฟอร์ม (create.turbo_stream.erb · บท 132) ส่งกลับไปแค่หน้าต่างที่กดส่ง · B = Turbo Stream broadcast (broadcasts_to + turbo_stream_from · บท 133) ส่งถึงทุกหน้าต่างที่เปิดลำโพงไว้

ตัวเลือกที่ 1 ผิด — lazy frame คือ "โหลดกรอบทีหลัง" ไม่ได้อัปเดตหลายจุด · ตัวเลือกที่ 2 กลับด้านกัน · ตัวเลือกที่ 4 ผิด — หน้าต่างของคนอื่นไม่ได้ส่งฟอร์ม จึงไม่มีใครได้คำตอบจาก create.turbo_stream.erb · ผลจริงในตาราง "AI พลาดประจำ": ลบ turbo_stream_from แล้วหน้าที่ส่งเองยังอัปเดต แต่อีกหน้าต่างนิ่ง — แปลว่าเป็นสองทางแยกกัน

จุดที่ต้องจำ: ถามตัวเองว่า "ใครเป็นคนขอ" — เราขอเอง = ตอบฟอร์ม · เราไม่ได้ขอแต่ต้องรู้ = broadcast

ข้อ 2 · AI ส่งงานมาบอก "ทำ disconnect แล้ว" · คุณรัน grep -n "disconnect" app/javascript/** ได้แค่ grep: app/javascript/controllers: Is a directory · ควรสรุปยังไง?

ตัวเลือกที่ 2 ถูก · ผลจริงในขั้น 3: คำสั่งแบบ ** ไม่เข้าไปในโฟลเดอร์ · grep -rn "disconnect" app/javascript เจอ character_counter_controller.js:15 ทันที

ตัวเลือกที่ 1 สรุปเกินหลักฐาน — ตัวตรวจพังเอง ไม่ใช่งานพัง · ตัวเลือกที่ 3 กับ 4 เชื่อผลของตัวตรวจที่ไม่ได้ทำงานจริง · ข้อความ "Is a directory" คือ grep บ่นว่าข้าม ไม่ใช่ "เจอ"

จุดที่ต้องจำ: ก่อนเชื่อผลของตัวตรวจ ลองให้มันจับของที่รู้คำตอบอยู่แล้วก่อน

ข้อ 3 · กรอกฟอร์มบทความใหม่แบบเว้นว่างแล้วกดบันทึก · หน้าไม่เปลี่ยน ไม่มีข้อความ error · log เขียน Completed 200 OK · Console มี Form responses must redirect to another location · น่าจะผิดตรงไหน?

ตัวเลือกที่ 1 ถูก · ผลจริงในตาราง "AI พลาดประจำ": ลบ status ออกบรรทัดเดียว ได้อาการนี้ครบทุกข้อ · ใส่กลับแล้วเห็น บันทึกไม่ได้ มี 3 จุดที่ต้องแก้

ตัวเลือกที่ 2 ผิด — ถ้าบันทึกผ่านจริง controller จะ redirect ไปหน้าบทความ ไม่ใช่ 200 ค้างที่เดิม · ตัวเลือกที่ 3 ผิด — ฟอร์มบทความใหม่ไม่ได้อยู่ในกรอบ และอาการกรอบไม่ตรงคือ Content missing · ตัวเลือกที่ 4 ผิด — turbo_stream_from เกี่ยวกับเสียงตามสายจากคนอื่น ไม่เกี่ยวกับคำตอบของฟอร์มที่เราส่ง

จุดที่ต้องจำ: ข้อมูลไม่ผ่านต้องตอบ 422 · "200 แต่หน้าไม่ขยับ" = กลิ่นของ status ที่หายไป

ข้อ 4 · ทำไมสไลด์ถึงบอกว่าเกณฑ์ "เปิด 2 แท็บ พิมพ์แท็บหนึ่ง อีกแท็บขึ้นเอง" เป็นเกณฑ์แบบที่ดีที่สุด?

ตัวเลือกที่ 4 ถูก · คำว่า "ทำครบแล้ว" ในคำตอบของ AI ทำให้คอมเมนต์เด้งในหน้าต่างของคุณไม่ได้ · ต้องมีโค้ดที่ทำงานจริงเท่านั้น (สไลด์หน้า 52)

ตัวเลือกที่ 1 ผิด — ข้อนี้ต้องเปิดเบราว์เซอร์ grep ตรวจไม่ได้ · ตัวเลือกที่ 2 ผิด — ผ่านข้อนี้แต่ลืม status (ข้อ 3 ของ quiz) ก็ได้ · ตัวเลือกที่ 3 กลับด้าน — ถ้าให้ AI ตรวจเองแล้วเราแค่อ่านรายงาน ก็กลับไปเป็น "อ่านสูตร" ไม่ใช่ "ชิม"

จุดที่ต้องจำ: เกณฑ์ที่ดีคือเกณฑ์ที่เรารันเองแล้วเห็นผลกับตา — เขียนแบบนี้ไว้ในทุกใบสั่งงาน

ถ้าเปิดสไลด์ดูประกอบ

หน้าสไลด์เขียนของจริงต้องสนใจไหม
53grep -n "disconnect" app/javascript/**ได้แค่ grep: app/javascript/controllers: Is a directory ไม่ค้นในโฟลเดอร์ · ใช้ grep -rn "disconnect" app/javascriptสนใจ — ขั้น 3
52broadcasts_to :post พร้อม later: trueturbo-rails 2.0.23 ไม่มีตัวเลือก later: · ส่งต่อให้ job เองอยู่แล้ว · ใบสั่งของเราแก้บรรทัดนี้สนใจ — ขั้น 1
53"ลืม status: :unprocessable_entity" (กฎ 12 ในหน้าเดียวกันเขียน _content)scaffold ของ Rails 8.1 ใช้ :unprocessable_content · เราใช้ตัวนี้ทั้งโปรเจกต์แค่รู้ไว้
52"ใช้ dom_id(post) ตั้ง id ทั้งสองฝั่ง"ในโปรเจกต์เรา dom_id(post) ถูก <article> ใช้ไปแล้ว จึงใช้ dom_id(post, :title) (บท 131) · หลักการเดียวกันแค่รู้ไว้
53ตามไม่ทัน git checkout day3-c-donebranch ของอาจารย์ใช้ i18n + rails-i18n · id ฟอร์มและเลขกฎใน 00-context ต่างกัน ไฟล์ไม่ตรงกับบทเรียนแค่รู้ไว้
54"บ่ายจะได้อีก 3 ไฟล์ แล้ววันอังคารอีก 2 ไฟล์"อ้างตามสไลด์ · ยังไม่มีสไลด์ชุดนั้นในเว็บนี้แค่รู้ไว้

อ้างอิง