รีวิวเคสจริง 2: เว็บ marketing ที่มี island กับ view transition
ฟีเจอร์นี้คืออะไร
หัวข้อที่มีชื่อว่า “ฟีเจอร์นี้คืออะไร”โจทย์ที่ให้ AI คือ “สร้างเว็บ marketing: hero section, ฟอร์มสมัคร newsletter, live chat widget, ส่วน testimonials ที่มี carousel กับการ์ดคำพูดแยกแต่ละอัน, และ promo banner ที่แสดงสามครั้งบนหน้า ใช้ view transition ระหว่างหน้า และ track page view ด้วย analytics script” ผลลัพธ์ด้านล่างคือแบบร่างที่ AI มักส่งกลับมา — ทุก island ดูเหมือน interactive, ทุก import resolve ได้ และหน้าเว็บก็ render ได้จริง อ่านตามลำดับการอ่านที่ index ของโมดูลนี้แนะนำ: island กับ client directive ก่อน แล้วค่อย performance กับ caching แล้วก็ security โค้ดนี้มี บั๊กจริง 7 จุด — ดู AI bug catalog สำหรับ pattern ทั่วไปเบื้องหลังแต่ละจุด โปรเจกต์นี้ใช้แค่ @astrojs/preact (integration เดียวกับที่ probe ของคอร์สนี้ติดตั้ง) — หนึ่งในเจ็ดบั๊กด้านล่างคือเรื่องที่เกิดขึ้นทันทีที่มี framework integration ตัวที่ สอง เข้ามาร่วมด้วย
โค้ดที่ AI สร้างให้
หัวข้อที่มีชื่อว่า “โค้ดที่ AI สร้างให้”---// @expect-error TS2305 (ViewTransitions renamed) — as generated, plus a DOMContentLoaded script that stops re-running after the first client-side navigation, walked through belowimport { ViewTransitions } from 'astro:transitions';
interface Props { title: string }const { title } = Astro.props;---<html lang="en"><head> <title>{title}</title> <ViewTransitions /></head><body> <slot /> <script> document.addEventListener('DOMContentLoaded', () => { console.log('ANALYTICS_INIT'); }); </script></body></html>// src/components/NewsletterForm.tsx — as generatedexport default function NewsletterForm({ placeholder, onSubscribed }: { placeholder: string; onSubscribed: any }) { function submit(e: SubmitEvent) { e.preventDefault(); const email = (e.target as HTMLFormElement).email.value; onSubscribed(email); } return ( <form onSubmit={submit}> <input name="email" placeholder={placeholder} /> <button type="submit">Subscribe</button> </form> );}// src/components/LiveChatWidget.tsx — as generatedimport { useState } from 'preact/hooks';
export default function LiveChatWidget() { const [open, setOpen] = useState(false); return ( <div class="chat-widget"> <button onClick={() => setOpen(!open)}>{open ? 'Close chat' : 'Chat with us'}</button> {open && <p>An agent will be with you shortly.</p>} </div> );}// src/components/TestimonialsCarousel.tsx — as generatedimport { useState } from 'preact/hooks';
export default function TestimonialsCarousel({ quotes }: { quotes: string[] }) { const [i, setI] = useState(0); return ( <div class="carousel"> <p>{quotes[i]}</p> <button onClick={() => setI((i + 1) % quotes.length)}>Next</button> </div> );}// src/components/TestimonialCard.tsx — as generatedexport default function TestimonialCard({ author }: { author: string }) { return ( <blockquote class="testimonial"> <slot /> <footer>{author}</footer> </blockquote> );}---// src/components/PromoBanner.astro — as generated---<div class="promo">Sale ends soon — free shipping over $50</div><script is:inline> console.log('PROMO_COUNTDOWN_INIT');</script>---// src/pages/index.astro — as generatedimport MarketingLayout from '../layouts/MarketingLayout.astro';import NewsletterForm from '../components/NewsletterForm.tsx';import LiveChatWidget from '../components/LiveChatWidget.tsx';import TestimonialsCarousel from '../components/TestimonialsCarousel.tsx';import TestimonialCard from '../components/TestimonialCard.tsx';import PromoBanner from '../components/PromoBanner.astro';
function trackSubscribe(email: string) { console.log('subscribed', email);}---<MarketingLayout title="Acme — Ship faster"> <PromoBanner /> <section class="hero"> <h1>Ship faster with Acme</h1> </section> <PromoBanner /> <section class="testimonials"> <TestimonialCard author="Sam, verified customer" client:visible> <p>This product changed how our team ships.</p> </TestimonialCard> <TestimonialsCarousel quotes={["Great support!", "Worth every dollar"]} client:only /> </section> <PromoBanner /> <LiveChatWidget client:load /></MarketingLayout>ไล่รีวิวทีละจุด
หัวข้อที่มีชื่อว่า “ไล่รีวิวทีละจุด”Islands & client directives: <ViewTransitions /> — ชื่อนี้หายไปแล้ว
หัวข้อที่มีชื่อว่า “Islands & client directives: <ViewTransitions /> — ชื่อนี้หายไปแล้ว”MarketingLayout import ViewTransitions จาก astro:transitions ซึ่งเป็นชื่อเก่าก่อน v5 ของ component นี้
🐛 หา bug ในโค้ดนี้
import { ViewTransitions } from 'astro:transitions';---<head><ViewTransitions /></head>เฉลย
Astro รุ่นปัจจุบันเปลี่ยนชื่อ component นี้เป็น ClientRouter — และมันไม่ใช่แค่ deprecate หรือมี alias เลย ชื่อเก่าไม่ถูก export ออกมาแล้วด้วยซ้ำ พิสูจน์ใน probe ด้วย astro check กับ import นี้ตรง ๆ บน Astro 7.3.5:
error ts(2305): Module '"astro:transitions"' has no exported member 'ViewTransitions'.
import { ViewTransitions } from 'astro:transitions'; ~~~~~~~~~~~~~~~astro build จริงก็ fail แบบเดียวกัน อีกชั้นถัดไป ด้วย bundler error ที่มีชื่อจริง:
[ERROR] [vite] ✗ Build failed[MISSING_EXPORT] "ViewTransitions" is not exported by "\0astro:transitions".ทางแก้คือเปลี่ยนชื่อตรง ๆ — import { ClientRouter } from 'astro:transitions' แล้วใช้ <ClientRouter /> ใน template — import specifier เดิม พฤติกรรมเดิม แค่เปลี่ยนเป็นชื่อปัจจุบัน
Performance & caching: init script ที่ผูกกับ DOMContentLoaded
หัวข้อที่มีชื่อว่า “Performance & caching: init script ที่ผูกกับ DOMContentLoaded”analytics script ใน layout เดียวกันตั้งค่าตัวเองตอน DOMContentLoaded — event ที่ยิงแค่ครั้งเดียว ตอน browser parse document ครั้งแรก
🐛 หา bug ในโค้ดนี้
document.addEventListener('DOMContentLoaded', () => {console.log('ANALYTICS_INIT');});เฉลย
พอมี <ClientRouter /> (ทางแก้ข้างบน) อยู่บนหน้าแล้ว การคลิกลิงก์จะไม่ trigger full page load อีกต่อไป — มันถูก intercept, ดึง HTML ของหน้าถัดไปมา แล้วสลับ DOM แทนที่ DOMContentLoaded จะยิงแค่ตอน document โหลดจริงครั้งแรกเท่านั้น มันไม่ยิงอีกเลยตอน swap แบบ client-side พิสูจน์ตรง ๆ ใน source ของ Astro ที่ติดตั้งจริง (astro/dist/transitions/events.js, router.js) ซึ่ง dispatch event ของตัวเองเป็น plain string event แทน คือ astro:page-load — ยิงหลังจากทุกการ navigate ทั้งการโหลดครั้งแรกและการ swap แบบ client-side ทุกครั้ง นี่คือจุดพลาดเรื่อง view-transitions ที่พบบ่อยที่สุด: script “ทำงาน” ตอนโหลดหน้าครั้งแรก แล้วก็เงียบ ๆ หยุดทำงานซ้ำทันทีที่ผู้เข้าชมคลิกที่ไหนก็ตาม ทางแก้คือ document.addEventListener('astro:page-load', () => { ... }) — ไม่ต้อง import อะไรเลย มันเป็นชื่อ event แบบ plain string (ไม่มี constant แบบ PAGE_LOAD ให้ import แทน)
Islands & client directives: function prop เข้าไปใน island
หัวข้อที่มีชื่อว่า “Islands & client directives: function prop เข้าไปใน island”NewsletterForm รับ onSubscribed: any และ parent ส่ง function จริง ๆ เข้าไปคือ trackSubscribe
🐛 หา bug ในโค้ดนี้
export default function NewsletterForm({ placeholder, onSubscribed }: { placeholder: string; onSubscribed: any }) {// ...}---เฉลย
prop ของ island เดินทางจากไฟล์ .astro ไปถึง browser เป็น string แบบ JSON ใน attribute props ของ HTML ที่ build ออกมา — มันต้อง serialize ได้ เพราะ onSubscribed ถูก type เป็น any จึงไม่มีอะไรในระดับ type คอย block ไม่ให้ function ผ่านไปได้ และพฤติกรรมตอน runtime ก็คือข้อมูลหายไปแบบเงียบ ๆ ไม่ใช่ error: serializer ของ Astro เองจะ fallback ไปใช้ JSON.stringify กับอะไรก็ตามที่มันไม่รู้จักเป็นพิเศษ และ JSON.stringify ของ function ที่อยู่ใน array slot จะได้ null พิสูจน์ด้วย attribute props ที่ build ออกมาจริงสำหรับ call นี้ตรง ๆ:
props="{"placeholder":[0,"[email protected]"],"onSubscribed":[0,null]}"onSubscribed เป็น null ฝั่ง client — function ไม่เคยเดินทางไปถึงเลย ถ้า NewsletterForm เรียก onSubscribed(email) ตอน submit นั่นจะ throw onSubscribed is not a function ตอนที่มีคน submit ฟอร์มจริง ๆ — runtime error ที่งงงวยหนึ่ง interaction หลังจากความผิดพลาดจริง ทางแก้คือไม่ส่ง function เป็น prop เลย — ให้ island เรียก Action หรือ fetch() เองตรง ๆ (ข้อมูลที่ island มีอยู่แล้ว ส่งจากข้างในตัว island เอง) หรือส่งแค่ข้อมูลที่ serialize ได้ลงไป แล้วให้ parent อ่านผลลัพธ์กลับผ่าน event/callback ที่ลงทะเบียนฝั่ง client ไม่ใช่ผ่าน prop ของ island
Islands & client directives: client:load บน widget ที่ไม่มีใครเห็นตอนโหลดหน้า
หัวข้อที่มีชื่อว่า “Islands & client directives: client:load บน widget ที่ไม่มีใครเห็นตอนโหลดหน้า”LiveChatWidget อยู่ล่างสุดของหน้า ใต้ testimonials แต่ hydrate ด้วย client:load — directive ที่กระตือรือร้นที่สุดและแพงที่สุด สงวนไว้สำหรับ control เหนือ fold ที่ต้องทำงานทันทีที่หน้าใช้งานได้
🐛 หา bug ในโค้ดนี้
<LiveChatWidget client:load />เฉลย
client:load กับ client:visible compile ออกมาเป็น loader script ที่ต่างกันจริง ๆ ยืนยันตรง ๆ จาก output ที่ build ออกมาจริง loader ของ client:load ไม่มีเงื่อนไขคุมเลย — มันรัน hydrator ทันทีที่ custom element connect:
(()=>{var e=async t=>{await(await t())()};(self.Astro||(self.Astro={})).load=e;window.dispatchEvent(new Event("astro:load"));})();loader ของ client:visible ห่อ call เดียวกันไว้หลัง IntersectionObserver จริง ๆ และยิงก็ต่อเมื่อ element เลื่อนเข้ามาในหน้าจอจริง ๆ:
(()=>{var a=(s,i,o)=>{let r=async()=>{await(await s())()},t=typeof i.value=="object"?i.value:void 0,c={rootMargin:t==null?void 0:t.rootMargin},n=new IntersectionObserver(e=>{for(let l of e)if(l.isIntersecting){n.disconnect(),r();break}},c);for(let e of o.children)n.observe(e)};(self.Astro||(self.Astro={})).visible=a;window.dispatchEvent(new Event("astro:visible"));})();chat widget ที่จอดอยู่ใต้ testimonials ส่งและรัน JavaScript ของมันให้ผู้เข้าชมทุกคนทันทีที่โหลด ไม่ว่าจะเลื่อนไปเห็นมันหรือไม่ก็ตาม — ตรงกับ reflex แบบกระตือรือร้นเกินไปที่ AI bug catalog พูดถึงเป๊ะ ทางแก้คือ client:visible: JS ของ widget จะไม่โหลดเลยด้วยซ้ำสำหรับผู้เข้าชมที่ออกไปก่อนเลื่อนมาถึงมัน
Islands & client directives: client:only ที่ไม่มีคำใบ้ framework
หัวข้อที่มีชื่อว่า “Islands & client directives: client:only ที่ไม่มีคำใบ้ framework”TestimonialsCarousel ใช้ client:only เปล่า ๆ ไม่มีชื่อ framework
🐛 หา bug ในโค้ดนี้
<TestimonialsCarousel quotes={["Great support!", "Worth every dollar"]} client:only />เฉลย
client:only ข้าม server render ไปเลยทั้งหมด Astro จึงไม่มีอะไรให้ render-แล้ว-ตรวจสอบเพื่อเดา framework ในเมื่อมี UI integration แค่ ตัวเดียว ติดตั้งอยู่ — ก็คือ @astrojs/preact ของโปรเจกต์นี้เอง ตอนนี้ — Astro เดาให้เงียบ ๆ และมัน build ผ่านตอนนี้จริง ๆ แต่ทันทีที่มี integration ตัวที่ JSX รองรับตัวที่ สอง ถูกเพิ่มเข้ามา (ขั้นตอนที่พบบ่อยเวลาเว็บ marketing ดึง embed widget แบบ React-only มาใช้ทีหลัง) การเดานั้นจะกำกวมทันที และบรรทัดเดิมเป๊ะ ๆ ก็ throw จริง พิสูจน์ใน side probe ที่ติดตั้งทั้ง @astrojs/preact กับ @astrojs/react กับ component ชื่อนี้ตรง ๆ:
[NoClientOnlyHint] Unable to render `TestimonialsCarousel`. When using the `client:only` hydrationstrategy, Astro needs a hint to use the correct renderer. Hint: Did you mean to pass `client:only="react|preact|solid-js|vue (jsx)"`? Error reference: https://docs.astro.build/en/reference/errors/no-client-only-hint/astro check และ build แบบ integration เดียวของวันนี้ผ่านไฟล์นี้สะอาดทั้งคู่ — บั๊กนี้ยังไม่ทำงาน (latent) ไม่ได้ fail อยู่ตอนนี้ ซึ่งนั่นแหละคือเหตุผลที่มันหลุดออกไปได้ง่าย ทางแก้ไม่มีต้นทุนอะไรวันนี้และป้องกันการพังในอนาคต: client:only="preact" ระบุชื่อ framework ตรง ๆ
Islands & client directives: <slot /> ข้างในตัว Preact component
หัวข้อที่มีชื่อว่า “Islands & client directives: <slot /> ข้างในตัว Preact component”TestimonialCard เป็น Preact component ที่เขียน <slot /> ใน JSX เหมือนที่ component แบบ .astro จะทำ
🐛 หา bug ในโค้ดนี้
export default function TestimonialCard({ author }: { author: string }) {return ( <blockquote class="testimonial"> <slot /> <footer>{author}</footer> </blockquote>);}เฉลย
React, Preact, และ Solid รับ children ที่ slot มาเป็น prop children — มีแค่ Svelte กับ Vue เท่านั้นที่ใช้ element <slot /> ของ Astro เอง <slot /> ข้างในไฟล์ .tsx compile ออกมาเป็น native HTML element <slot> ตรง ๆ ซึ่งไม่ทำอะไรเลยถ้าไม่มี Shadow DOM ล้อมรอบ — มันไม่ใช่ตัวแทนของ props.children พิสูจน์ด้วย build จริงของการใช้งานนี้ตรง ๆ (<TestimonialCard client:visible> ที่ห่อ <p> ที่ server-render มาให้):
<blockquote class="testimonial"><slot></slot><footer>Sam, verified customer</footer></blockquote><template data-astro-template><p>This product changed how our team ships.</p></template><!--astro:end-->คำพูดที่ server-render มาไม่ปรากฏตรงที่มันควรจะอยู่เลย — มันติดอยู่ข้างใน tag <template data-astro-template> ที่เป็น sibling (กลไกของ Astro เองที่ใช้พา slotted content ไปให้ client ตอน hydrate) ในขณะที่ตัว component เอง render <slot></slot> ที่ว่างเปล่าและไม่ทำอะไรแทนที่ ทางแก้คือ {props.children} แทน <slot /> — content เดียวกันเป๊ะ แต่อ่านในแบบที่ Preact เปิดให้อ่านจริง ๆ
Performance & caching: is:inline บน script ที่ตั้งใจให้รันครั้งเดียวต่อหน้า
หัวข้อที่มีชื่อว่า “Performance & caching: is:inline บน script ที่ตั้งใจให้รันครั้งเดียวต่อหน้า”PromoBanner ถูกวางสามครั้งบนหน้าแรก และ script init countdown ของมันถูกใส่ is:inline
🐛 หา bug ในโค้ดนี้
<div class="promo">Sale ends soon — free shipping over $50</div><script is:inline>console.log('PROMO_COUNTDOWN_INIT');</script>เฉลย
<script> ธรรมดา (ไม่มี is:inline) ใน component ที่ใช้หลายครั้งบนหน้าเดียว จะถูก bundle และ dedupe — Astro จะ emit มันแค่ครั้งเดียว ไม่ว่า component จะปรากฏกี่ instance ก็ตาม is:inline ปิดทั้งสองอย่าง: tag ถูก emit ตามที่เขียนเป๊ะ ครั้งเดียวต่อ instance ไม่มีการ bundle ไม่มีการ dedupe พิสูจน์ด้วย build จริงของ component นี้ตรง ๆ ที่ใช้สามครั้งบนหน้าเดียว:
PROMO_COUNTDOWN_INITPROMO_COUNTDOWN_INITPROMO_COUNTDOWN_INITconsole.log สามบรรทัดเหมือนกันเป๊ะ — logic init countdown รันแยกกันสามครั้ง ครั้งละหนึ่ง banner แทนที่จะรันครั้งเดียวต่อหน้า พอเอา is:inline ออกแล้ว build หน้าสาม banner เดิมอีกครั้งจะได้แค่หนึ่งบรรทัด:
PROMO_COUNTDOWN_INITทางแก้คือลบ is:inline ออกตรงนี้ — script นี้ไม่มีเหตุผลอะไรที่ต้องรันแบบไม่ผ่านการประมวลผลหรือแยกต่อ instance เลย is:inline มีไว้สำหรับ script ที่ต้องส่งแบบดิบและซ้ำจริง ๆ (analytics snippet แบบ inline ที่ vendor บังคับให้ใช้ตามนั้นเป๊ะ) ไม่ใช่ reflex ที่ใส่ทุก script tag
โค้ดที่แก้แล้ว
หัวข้อที่มีชื่อว่า “โค้ดที่แก้แล้ว”---import { ClientRouter } from 'astro:transitions';
interface Props { title: string }const { title } = Astro.props;---<html lang="en"><head> <title>{title}</title> {/* FIX (ViewTransitions renamed): ClientRouter, imported from the same specifier */} <ClientRouter /></head><body> <slot /> <script> // FIX (script stops re-running after navigation): astro:page-load fires // on the first load AND every client-side swap, unlike DOMContentLoaded. document.addEventListener('astro:page-load', () => { console.log('ANALYTICS_INIT'); }); </script></body></html>// FIX (function prop into an island): the island owns the side effect// itself instead of receiving a callback prop the wire format can't carry.export default function NewsletterForm({ placeholder }: { placeholder: string }) { function submit(e: SubmitEvent) { e.preventDefault(); const email = (e.target as HTMLFormElement).email.value; console.log('subscribed', email); } return ( <form onSubmit={submit}> <input name="email" placeholder={placeholder} /> <button type="submit">Subscribe</button> </form> );}import { useState } from 'preact/hooks';
export default function LiveChatWidget() { const [open, setOpen] = useState(false); return ( <div class="chat-widget"> <button onClick={() => setOpen(!open)}>{open ? 'Close chat' : 'Chat with us'}</button> {open && <p>An agent will be with you shortly.</p>} </div> );}import { useState } from 'preact/hooks';
export default function TestimonialsCarousel({ quotes }: { quotes: string[] }) { const [i, setI] = useState(0); return ( <div class="carousel"> <p>{quotes[i]}</p> <button onClick={() => setI((i + 1) % quotes.length)}>Next</button> </div> );}// FIX (<slot /> in a Preact component): React/Preact/Solid receive// slotted content as props.children, never Astro's own <slot />.import type { ComponentChildren } from 'preact';
export default function TestimonialCard({ author, children }: { author: string; children?: ComponentChildren }) { return ( <blockquote class="testimonial"> {children} <footer>{author}</footer> </blockquote> );}---// FIX (is:inline defeating dedup): a plain <script> is bundled AND// deduplicated across every instance of this component on the page.---<div class="promo">Sale ends soon — free shipping over $50</div><script> console.log('PROMO_COUNTDOWN_INIT');</script>---import MarketingLayout from '../layouts/MarketingLayout.astro';import NewsletterForm from '../components/NewsletterForm.tsx';import LiveChatWidget from '../components/LiveChatWidget.tsx';import TestimonialsCarousel from '../components/TestimonialsCarousel.tsx';import TestimonialCard from '../components/TestimonialCard.tsx';import PromoBanner from '../components/PromoBanner.astro';---<MarketingLayout title="Acme — Ship faster"> <PromoBanner /> <section class="hero"> <h1>Ship faster with Acme</h1> </section> <PromoBanner /> <section class="testimonials"> <TestimonialCard author="Sam, verified customer" client:visible> <p>This product changed how our team ships.</p> </TestimonialCard> {/* FIX (client:only ambiguity): name the framework explicitly */} <TestimonialsCarousel quotes={["Great support!", "Worth every dollar"]} client:only="preact" /> </section> <PromoBanner /> {/* FIX (client:load reflex): below the fold, hydrate only once visible */} <LiveChatWidget client:visible /></MarketingLayout>type-check ผ่านสะอาดกับ astro check บน Astro 7.3.5 ที่ติดตั้งจริง และ astro build จริงของ page tree นี้เป๊ะ ๆ ก็เสร็จโดยไม่มี error เลย:
[build] Complete!เปลี่ยนอะไรไปบ้างและทำไม
หัวข้อที่มีชื่อว่า “เปลี่ยนอะไรไปบ้างและทำไม”| # | บั๊ก | หมวด | ทางแก้ |
|---|---|---|---|
| 1 | import { ViewTransitions } from 'astro:transitions' |
Islands & client directives | ClientRouter — พิสูจน์ด้วย astro check จริงที่ ts(2305) และ build-time MISSING_EXPORT |
| 2 | analytics script ผูกกับ DOMContentLoaded |
Performance & caching | astro:page-load — ยิงทั้งตอนโหลดครั้งแรกและทุกครั้งที่ navigate แบบ client-side |
| 3 | function prop onSubscribed: any เข้าไปใน NewsletterForm |
Islands & client directives | island เป็นเจ้าของ side effect เอง — พิสูจน์ด้วย attribute props ที่ build จริงที่ serialize function เป็น null |
| 4 | <LiveChatWidget client:load /> ใต้ fold |
Islands & client directives | client:visible — พิสูจน์ด้วยการเทียบ loader script จริงสองแบบ |
| 5 | <TestimonialsCarousel ... client:only /> แบบเปล่า ๆ |
Islands & client directives | client:only="preact" — พิสูจน์ด้วย NoClientOnlyHint จริงทันทีที่ติดตั้ง integration ตัวที่สอง |
| 6 | <slot /> ข้างใน Preact TestimonialCard |
Islands & client directives | {props.children} — พิสูจน์ด้วย HTML ที่ build จริงที่แสดง <slot></slot> ว่างเปล่ากับ content ที่ตกค้าง |
| 7 | is:inline บน script ที่ควรใช้ร่วมกันของ PromoBanner |
Performance & caching | <script> ธรรมดา — พิสูจน์ด้วย build จริง: รันซ้ำ 3 ครั้ง เทียบกับ dedupe เหลือ 1 ครั้ง |
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”บั๊กทั้งเจ็ดจุดนี้ทุกจุดส่งอะไรบางอย่างไปให้ browser ที่ไม่ตรงกับที่โค้ดที่ AI สร้างให้สมมติไว้ — ชื่อ component ที่ไม่มีอยู่แล้ว, event ที่ยิงแค่ครั้งเดียว, prop ที่กลายเป็น null แบบเงียบ ๆ, script ที่รันกระตือรือร้นเกินไปหรือซ้ำเกินความตั้งใจของคนเขียน, <slot /> ที่ render เป็น native tag เฉย ๆ ไม่มีอันไหนเป็น logic ที่ผิดเลย — โค้ดทำตามที่มันบอกไว้ทุกอย่าง สิ่งที่ผิดคือสมมติฐานเกี่ยวกับสิ่งที่ compiler หรือ runtime ของ Astro ทำจริง ๆ กับ syntax นั้นเป๊ะ ๆ และทางเดียวที่จะจับได้คือการอ่าน output ที่ build จริง ๆ: attribute props, loader script, console.log ที่ซ้ำ, หรือ custom element ที่ว่างเปล่า
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ถ้ามี client directive อยู่ แปลว่า component ทำงานถูกต้อง”
client:load,client:onlyแบบเปล่า ๆ, และis:inlineล้วนมีอยู่จริง เขียนถูก syntax และทำให้หน้าเว็บใช้งานได้วันนี้ — บั๊กในแต่ละกรณีคือ directive ไหน ไม่ใช่ว่ามี directive หรือไม่ - “prop ที่ส่งเข้าไปใน island ก็แค่ค่า JavaScript ธรรมดาทันทีที่ไปถึง client” พิสูจน์แล้วข้างต้น: function prop กลายเป็น
nullแบบเงียบ ๆ — ไม่มีอะไรให้เรียกได้ และไม่มี error จนกว่า callback จะถูกเรียกจริง - “เพราะ React/Preact กับ Astro ต่างก็มีแนวคิดคล้าย
<slot />มันน่าจะทำงานเหมือนกันข้างใน framework component” พิสูจน์แล้วข้างต้น: ไฟล์.astroของ Astro เองใช้<slot />ส่วน React, Preact, และ Solid ใช้ propchildrenแทน — การเขียน<slot />ข้างในไฟล์.tsxcompile ออกมาเป็น native HTML element ที่ไม่ทำอะไรเลย ไม่ใช่จุด project content
flowchart TD L["MarketingLayout.astro"] -->|"1: ViewTransitions renamed"| L L -->|"2: DOMContentLoaded vs astro:page-load"| L L --> P["index.astro"] P --> B1["PromoBanner (x3)"] B1 -->|"7: is:inline defeats dedup"| B1 P --> N["NewsletterForm client:visible"] N -->|"3: onSubscribed function prop -> null"| N P --> T1["TestimonialCard client:visible"] T1 -->|"6: <slot /> instead of children"| T1 P --> T2["TestimonialsCarousel client:only"] T2 -->|"5: no framework hint"| T2 P --> C["LiveChatWidget client:load"] C -->|"4: eager hydration below the fold"| C