diff --git a/.changeset/atproto-comments-external-svelte.md b/.changeset/atproto-comments-external-svelte.md
new file mode 100644
index 0000000..807ac8e
--- /dev/null
+++ b/.changeset/atproto-comments-external-svelte.md
@@ -0,0 +1,17 @@
+---
+"@svebcomponents/atproto.comments": patch
+---
+
+Ship a Svelte-external build and route bundler consumers to it via the `svelte`
+export condition, fixing a hydration crash (`Cannot read properties of null
+(reading 'f')`) that wiped a correctly server-rendered thread on the client.
+
+The client build bundled its own Svelte runtime. In a SvelteKit/Vite app —
+which has its own Svelte — that put **two** Svelte runtimes on the page, and
+under `experimental.async` the app's async-boundary hydration would cross into
+the component's separate runtime and dereference a null effect. The component
+now also emits `dist/client-svelte`/`dist/server-svelte` (Svelte marked
+external), and its package `exports` declare a `svelte` condition pointing at
+them, so bundlers dedupe the component onto the app's single Svelte runtime.
+The default `dist/client` (Svelte bundled) is unchanged and still serves the
+CDN / bare-`import` drop-in, which has no app Svelte to dedupe against.
diff --git a/apps/host/src/routes/+page.svelte b/apps/host/src/routes/+page.svelte
index 9da0057..b592494 100644
--- a/apps/host/src/routes/+page.svelte
+++ b/apps/host/src/routes/+page.svelte
@@ -152,9 +152,8 @@ import "@svebcomponents/atproto.comments";`;
- Turn any ATProto thread into a native comment section. Readers reply
- with their own accounts, posts stay in their own repos, and your site
- stays refreshingly database-free.
+ Any ATProto thread, as a native comment section — no database, no
+ lock-in.
Add to your site
@@ -177,7 +176,8 @@ import "@svebcomponents/atproto.comments";`;
<
script type=
"module"
src=
"https://cdn.jsdelivr.net/npm/@svebcomponents/atproto.comments""https://cdn.jsdelivr.net/npm/
@svebcomponents/
atproto.comments">
</
script>
@@ -631,9 +631,9 @@ import "@svebcomponents/atproto.comments";`;
.hero {
min-height: 670px;
display: grid;
- grid-template-columns: 1.02fr 0.98fr;
+ grid-template-columns: 0.72fr 1.28fr;
align-items: center;
- gap: clamp(3rem, 8vw, 7rem);
+ gap: clamp(2rem, 6vw, 4rem);
padding: 5.5rem 0 6.5rem;
}
@@ -669,8 +669,8 @@ import "@svebcomponents/atproto.comments";`;
}
h1 {
- max-width: 700px;
- font-size: clamp(4rem, 7.2vw, 6.5rem);
+ max-width: 420px;
+ font-size: clamp(2.75rem, 4.6vw, 4.25rem);
}
h1 em {
@@ -683,11 +683,11 @@ import "@svebcomponents/atproto.comments";`;
}
.lede {
- max-width: 590px;
- margin: 2rem 0 0;
+ max-width: 420px;
+ margin: 1.5rem 0 0;
color: #514e49;
- font-size: clamp(1.08rem, 1.8vw, 1.3rem);
- line-height: 1.62;
+ font-size: clamp(1rem, 1.4vw, 1.1rem);
+ line-height: 1.55;
}
.hero-actions {
@@ -776,7 +776,7 @@ import "@svebcomponents/atproto.comments";`;
background: #202025;
color: #f7f5f0;
font:
- 0.84rem/1.75 ui-monospace,
+ 0.95rem/1.75 ui-monospace,
SFMono-Regular,
Menlo,
monospace;
diff --git a/components/atproto-comments/package.json b/components/atproto-comments/package.json
index 7f190b1..830ed61 100644
--- a/components/atproto-comments/package.json
+++ b/components/atproto-comments/package.json
@@ -26,10 +26,12 @@
"exports": {
".": {
"types": "./dist/client/index.d.ts",
+ "svelte": "./dist/client-svelte/index.js",
"import": "./dist/client/index.js"
},
"./ssr": {
"types": "./dist/server/ssr.d.ts",
+ "svelte": "./dist/server-svelte/ssr.js",
"import": "./dist/server/ssr.js"
}
},
diff --git a/components/atproto-comments/svebcomponents.config.ts b/components/atproto-comments/svebcomponents.config.ts
index 5b72506..46c4c6c 100644
--- a/components/atproto-comments/svebcomponents.config.ts
+++ b/components/atproto-comments/svebcomponents.config.ts
@@ -1,33 +1,50 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "@svebcomponents/build";
-const configs = defineConfig();
const hydrationHost = fileURLToPath(
new URL("./src/HydrationHost.svelte", import.meta.url),
);
-// @svebcomponents/ssr 0.3.0's shipped HydrationHost destructures $props().
-// Under Svelte 5.56's async SSR integration that produces writable prop
-// signals without a parent effect and hydration fails before the user's
-// component mounts. Keep the host local until that upstream package includes
-// the equivalent direct-props fix.
-configs[0] = {
- ...configs[0],
- alias: {
- ...configs[0].alias,
- "@svebcomponents/ssr/hydration-host": hydrationHost,
- },
- noExternal: [
- ...(configs[0].noExternal ?? []),
- /@svebcomponents\/ssr\/hydration/,
- ],
-};
+// Emit the Svelte-external build variants (dist/client-svelte, dist/server-svelte)
+// alongside the default Svelte-bundled build. A bundler consumer (SvelteKit,
+// Vite) resolves the package's `svelte` export condition to these, so the
+// component shares the host app's single Svelte runtime instead of bundling its
+// own. Two runtimes in one page is what produces the
+// "Cannot read properties of null (reading 'f')" crash when the app's
+// async-boundary hydration (experimental.async) crosses into the component's
+// separately-bundled runtime — svebcomponents/atproto#7. The default bundled
+// build (dist/client) stays for the CDN / bare `import` drop-in, which has no
+// app Svelte to dedupe against.
+const configs = defineConfig({
+ svelteOutDir: "dist/client-svelte",
+ ssrSvelteOutDir: "dist/server-svelte",
+});
-// Hydration requires byte-for-byte-equivalent host structure on both sides.
-// Point the generated server host at the same local source as the client.
-configs[2] = {
- ...configs[2],
- entry: { "ssr-hydration-host": hydrationHost },
-};
+// @svebcomponents/ssr's shipped HydrationHost destructures $props(). Under
+// Svelte 5.56's async SSR that produces writable prop signals without a parent
+// effect and hydration fails before the user's component mounts. Keep a local
+// direct-props host, on *every* build variant (bundled + svelte-external, both
+// halves of each side), until upstream ships the equivalent fix. Matching by
+// outDir/entry rather than array index keeps this correct as the build's config
+// ordering evolves.
+for (const config of configs) {
+ const outDir = typeof config.outDir === "string" ? config.outDir : "";
+ // Client builds bundle the host: redirect its import to the local source.
+ if (outDir === "dist/client" || outDir === "dist/client-svelte") {
+ config.alias = {
+ ...config.alias,
+ "@svebcomponents/ssr/hydration-host": hydrationHost,
+ };
+ }
+ // Server host builds compile the host itself: point them at the local source
+ // so both sides stay byte-for-byte-equivalent.
+ if (
+ config.entry &&
+ typeof config.entry === "object" &&
+ "ssr-hydration-host" in config.entry
+ ) {
+ config.entry = { ...config.entry, "ssr-hydration-host": hydrationHost };
+ }
+}
export default configs;