Worked review 2: a marketing site with islands and view transitions
The feature
Section titled “The feature”The prompt was: “Build a marketing site: a hero section, a newsletter signup form, a live chat widget, a testimonials section with a carousel and individual quote cards, and a promo banner shown three times on the page. Use view transitions between pages, and track page views with an analytics script.” The result below is the kind of first draft an AI assistant hands back — every island looks interactive, every import resolves, and the page genuinely renders. Read it the way the reading order in this module’s index recommends: islands and client directives first, then performance and caching, then security. It has 7 real bugs — check the AI bug catalog for the general pattern behind each one. This project uses only @astrojs/preact (the same integration this course’s probe installs) — one of the seven bugs below is specifically about what happens the moment a second framework integration ever joins it.
The generated code
Section titled “The generated code”---// @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>The review walk
Section titled “The review walk”Islands & client directives: <ViewTransitions /> — the name is gone
Section titled “Islands & client directives: <ViewTransitions /> — the name is gone”MarketingLayout imports ViewTransitions from astro:transitions, the pre-v5 name for the component.
🐛 Spot the bug
import { ViewTransitions } from 'astro:transitions';---<head><ViewTransitions /></head>Show the answer
Current Astro renamed this component to ClientRouter — and it isn’t just deprecated or aliased, the old name is not exported at all. Proven in the probe with astro check against this exact import on Astro 7.3.5:
error ts(2305): Module '"astro:transitions"' has no exported member 'ViewTransitions'.
import { ViewTransitions } from 'astro:transitions'; ~~~~~~~~~~~~~~~A real astro build fails the same way, one layer later, with a named bundler error:
[ERROR] [vite] ✗ Build failed[MISSING_EXPORT] "ViewTransitions" is not exported by "\0astro:transitions".The fix is a straight rename — import { ClientRouter } from 'astro:transitions' and <ClientRouter /> in the template — same import specifier, same behavior, just the current name.
Performance & caching: an init script bound to DOMContentLoaded
Section titled “Performance & caching: an init script bound to DOMContentLoaded”The analytics script in the same layout sets itself up on DOMContentLoaded — the event that fires once, when the browser first parses the document.
🐛 Spot the bug
document.addEventListener('DOMContentLoaded', () => {console.log('ANALYTICS_INIT');});Show the answer
Once <ClientRouter /> (the fix above) is on the page, a link click no longer triggers a full page load — it’s intercepted, the next page’s HTML is fetched, and the DOM is swapped in place. DOMContentLoaded only ever fires on that first, real document load; it never fires again on a client-side swap, proven directly in the installed Astro source (astro/dist/transitions/events.js, router.js), which instead dispatches its own plain string event, astro:page-load, after every navigation — the first load and every subsequent client-side swap alike. This is the single most common view-transitions gotcha: the script “works” on the first page load and silently stops re-running the instant a visitor clicks anywhere. The fix is document.addEventListener('astro:page-load', () => { ... }) — no import needed, it’s a plain string event name (there is no exported PAGE_LOAD-style constant to import instead).
Islands & client directives: a function prop into an island
Section titled “Islands & client directives: a function prop into an island”NewsletterForm takes onSubscribed: any and the parent hands it a real function, trackSubscribe.
🐛 Spot the bug
export default function NewsletterForm({ placeholder, onSubscribed }: { placeholder: string; onSubscribed: any }) {// ...}---Show the answer
Island props travel from the .astro file to the browser as a JSON-ish string in the built HTML’s props attribute — they must be serializable. Because onSubscribed is typed any, nothing at the type level stops a function from being passed, and the runtime behavior is silent data loss, not an error: Astro’s own serializer falls through to JSON.stringify for anything it doesn’t specifically recognize, and JSON.stringify of a function inside an array slot produces null. Proven with the real built props attribute for this exact call:
props="{"placeholder":[0,"[email protected]"],"onSubscribed":[0,null]}"onSubscribed is null on the client — the function simply never made the trip. If NewsletterForm calls onSubscribed(email) on submit, that throws onSubscribed is not a function at the moment someone actually submits the form — a confusing runtime error one interaction after the actual mistake. The fix is not passing a function as a prop at all: have the island call an Action or a plain fetch() itself (data the island already has, sent from inside the island), or pass only serializable data down and let the parent read the result back through an event/callback registered client-side, never as an island prop.
Islands & client directives: client:load on a widget nobody sees on page load
Section titled “Islands & client directives: client:load on a widget nobody sees on page load”LiveChatWidget sits at the very bottom of the page, below the testimonials, but hydrates with client:load — the most eager, most expensive directive, reserved for above-the-fold controls that must work the instant the page is usable.
🐛 Spot the bug
<LiveChatWidget client:load />Show the answer
client:load and client:visible compile into genuinely different loader scripts, verified directly in the real built output. client:load’s loader has no gating condition at all — it runs the hydrator the moment the custom element connects:
(()=>{var e=async t=>{await(await t())()};(self.Astro||(self.Astro={})).load=e;window.dispatchEvent(new Event("astro:load"));})();client:visible’s loader wraps the same call behind a real IntersectionObserver, and only fires once the element actually scrolls into the viewport:
(()=>{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"));})();A chat widget parked below the testimonials ships and executes its JavaScript for every visitor immediately on load, whether or not they ever scroll far enough to see it — exactly the eager-by-default reflex the AI bug catalog calls out. The fix is client:visible: the widget’s JS never even loads for a visitor who leaves before scrolling to it.
Islands & client directives: client:only with no framework hint
Section titled “Islands & client directives: client:only with no framework hint”TestimonialsCarousel uses bare client:only, with no framework name.
🐛 Spot the bug
<TestimonialsCarousel quotes={["Great support!", "Worth every dollar"]} client:only />Show the answer
client:only skips server rendering entirely, so Astro has nothing to render-and-inspect to guess the framework from. With exactly one UI integration installed — this project’s own @astrojs/preact, today — Astro quietly infers it, and this builds fine right now. The moment a second JSX-capable integration is ever added (a common step when a marketing site later pulls in a React-only embed widget), that inference becomes ambiguous, and the exact same line throws for real. Proven in a side probe with @astrojs/preact and @astrojs/react both installed, against this exact component name:
[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 and today’s single-integration build both pass this file clean — the bug is latent, not currently failing, which is exactly why it’s easy to ship. The fix costs nothing today and prevents the future break: client:only="preact", naming the framework explicitly.
Islands & client directives: <slot /> inside a Preact component
Section titled “Islands & client directives: <slot /> inside a Preact component”TestimonialCard is a Preact component that writes <slot /> in its JSX, the way an .astro component would.
🐛 Spot the bug
export default function TestimonialCard({ author }: { author: string }) {return ( <blockquote class="testimonial"> <slot /> <footer>{author}</footer> </blockquote>);}Show the answer
React, Preact, and Solid receive slotted children as a children prop — only Svelte and Vue use Astro’s own <slot /> element. <slot /> inside a .tsx file compiles to a literal, native HTML <slot> element, which does nothing without a Shadow DOM around it — it is not a stand-in for props.children. Proven with a real build of this exact usage (<TestimonialCard client:visible> wrapping a server-rendered <p>):
<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-->The server-rendered quote never appears where it’s supposed to — it’s stranded inside a sibling <template data-astro-template> tag (Astro’s own mechanism for carrying slotted content to the client for hydration), while the component itself rendered an empty, inert <slot></slot> in its place. The fix is {props.children} in place of <slot /> — the same content, read the way Preact actually exposes it.
Performance & caching: is:inline on a script meant to run once per page
Section titled “Performance & caching: is:inline on a script meant to run once per page”PromoBanner is placed three times on the homepage, and its countdown-init script is marked is:inline.
🐛 Spot the bug
<div class="promo">Sale ends soon — free shipping over $50</div><script is:inline>console.log('PROMO_COUNTDOWN_INIT');</script>Show the answer
A plain (non-is:inline) <script> in a component used multiple times on one page is bundled and deduplicated — Astro emits it exactly once, no matter how many instances of the component appear. is:inline opts out of both: the tag is emitted exactly as written, once per instance, with no bundling and no dedup. Proven with a real build of this exact component used three times on one page:
PROMO_COUNTDOWN_INITPROMO_COUNTDOWN_INITPROMO_COUNTDOWN_INITThree identical console.log lines — the countdown-init logic runs three separate times, once per banner, instead of once for the page. Removing is:inline and building the identical three-banner page again produces exactly one:
PROMO_COUNTDOWN_INITThe fix is deleting is:inline here — this script has no reason to run unprocessed or per-instance. is:inline is for a script that genuinely must ship raw and repeated (an inline analytics snippet a vendor requires verbatim), not a reflex applied to every script tag.
The corrected code
Section titled “The corrected code”---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-checked clean against astro check on the installed Astro 7.3.5, and a real astro build of this exact page tree completes with no errors:
[build] Complete!What changed and why
Section titled “What changed and why”| # | Bug | Category | Fix |
|---|---|---|---|
| 1 | import { ViewTransitions } from 'astro:transitions' |
Islands & client directives | ClientRouter — proven with a real astro check ts(2305) and a build-time MISSING_EXPORT |
| 2 | Analytics script bound to DOMContentLoaded |
Performance & caching | astro:page-load — fires on first load and every client-side navigation |
| 3 | onSubscribed: any function prop into NewsletterForm |
Islands & client directives | The island owns the side effect itself — proven with a real built props attribute serializing the function to null |
| 4 | <LiveChatWidget client:load /> below the fold |
Islands & client directives | client:visible — proven by contrasting the two real built loader scripts |
| 5 | Bare <TestimonialsCarousel ... client:only /> |
Islands & client directives | client:only="preact" — proven with a real NoClientOnlyHint once a second integration is installed |
| 6 | <slot /> inside a Preact TestimonialCard |
Islands & client directives | {props.children} — proven with real built HTML showing an empty <slot></slot> and orphaned content |
| 7 | is:inline on PromoBanner’s shared script |
Performance & caching | Plain <script> — proven with a real build: 3 duplicated runs vs. 1 deduplicated run |
Mental Model
Section titled “Mental Model”Every one of these seven bugs ships something to the browser that isn’t what the AI-generated code assumed — a component name that no longer exists, an event that only fires once, a prop that silently becomes null, a script that runs eagerly or repeatedly against the author’s intent, a <slot /> that renders as an inert native tag. None of them are wrong logic — the code does exactly what it says. What’s wrong is the assumption about what Astro’s compiler or runtime actually does with that exact syntax, and the only way to catch that is to read the real, built output: a props attribute, a loader script, a duplicated console.log, an empty custom element.
Common Misconceptions
Section titled “Common Misconceptions”- “If a client directive is present, the component is behaving correctly.”
client:load, bareclient:only, andis:inlineare all present, syntactically valid, and produce a working page today — the bug in each case is which directive, not whether one exists at all. - “A prop passed into an island is just a normal JavaScript value once it’s on the client.” Proven above: a function prop silently becomes
null— nothing you can call, and no error until the callback actually fires. - “Since React/Preact and Astro both use
<slot />-like concepts, they work the same way inside a framework component.” Proven above: Astro’s own.astrofiles use<slot />; React, Preact, and Solid use achildrenprop instead — writing<slot />inside a.tsxfile compiles to an inert native HTML element, not a content projection point.
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