Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/stack/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@btst/stack",
"version": "3.0.0",
"version": "3.0.1",
"description": "A composable, plugin-based library for building full-stack applications.",
"repository": {
"type": "git",
Expand Down
10 changes: 8 additions & 2 deletions packages/stack/registry/btst-blog.json

Large diffs are not rendered by default.

78 changes: 78 additions & 0 deletions packages/stack/src/plugins/blog/__tests__/date-hydration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// @vitest-environment jsdom
import { act } from "react";
import { renderToString } from "react-dom/server";
import { hydrateRoot } from "react-dom/client";
import { describe, expect, it } from "vitest";
import { StackProvider } from "@btst/stack/context";
import { createTestClientStack } from "../../../__tests__/client-stack-test-utils";
import { blogClientPlugin } from "../client/plugin";
import { PostCard } from "../client/components/shared/post-card";
import type { SerializedPost } from "../types";

(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;

const post = {
id: "date-post",
title: "Timezone regression",
content: "Article content",
excerpt: "Article excerpt",
slug: "timezone-regression",
published: true,
image: "",
tags: [],
authorId: undefined,
publishedAt: "2026-04-28T18:30:00.000Z",
createdAt: "2026-01-01T00:30:00.000Z",
updatedAt: "2026-04-28T18:30:00.000Z",
} satisfies SerializedPost;

describe("public blog date hydration", () => {
for (const published of [true, false]) {
for (const timezone of ["America/Los_Angeles", "Pacific/Auckland"]) {
it(`hydrates a ${published ? "published post" : "draft"} in ${timezone} from UTC HTML`, async () => {
const previousTZ = process.env.TZ;
const item = {
...post,
published,
publishedAt: published ? post.publishedAt : undefined,
};
const timestamp = item.publishedAt || item.createdAt;
const expected = published ? "April 28, 2026" : "January 1, 2026";
const stack = createTestClientStack({ blog: blogClientPlugin() });
const ui = (
<StackProvider stack={stack}>
<PostCard post={item} />
</StackProvider>
);
const container = document.createElement("div");
const errors: unknown[] = [];
let root: ReturnType<typeof hydrateRoot> | undefined;
try {
process.env.TZ = "UTC";
const html = renderToString(ui);
container.innerHTML = html;
document.body.appendChild(container);
process.env.TZ = timezone;
// Both the card's text and machine-readable date must match across hosts.
expect(renderToString(ui)).toBe(html);
await act(async () => {
root = hydrateRoot(container, ui, {
onRecoverableError: (error) => errors.push(error),
});
});
expect(errors).toEqual([]);
expect(container.querySelector("time")?.textContent).toBe(expected);
expect(
container.querySelector("time")?.getAttribute("datetime"),
).toBe(timestamp);
} finally {
if (root) await act(async () => root!.unmount());
container.remove();
if (previousTZ === undefined)
Reflect.deleteProperty(process.env, "TZ");
else process.env.TZ = previousTZ;
}
});
}
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
useStack,
useTranslate,
} from "@btst/stack/context";
import { formatDate } from "date-fns";
import { formatPostDate } from "../shared/format-post-date";
import {
useSuspensePost,
useNextPreviousPosts,
Expand Down Expand Up @@ -157,7 +157,7 @@ function PostHeaderTop({ post }: { post: SerializedPost }) {
return (
<div className="flex flex-row items-center justify-center gap-2 flex-wrap mt-8">
<span className="font-light text-muted-foreground text-sm">
{formatDate(post.createdAt, "MMMM d, yyyy")}
{formatPostDate(post.createdAt)}
</span>
<CollapsibleTagList tags={post.tags ?? []} />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Public article dates must render identically during SSR and browser hydration.
// Keep the existing English date style, with an explicit publication timezone.
const formatter = new Intl.DateTimeFormat("en-US", {
month: "long",
day: "numeric",
year: "numeric",
timeZone: "UTC",
});

export function formatPostDate(date: string) {
return formatter.format(new Date(date));
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
useStack,
useTranslate,
} from "@btst/stack/context";
import { formatDate } from "date-fns";
import { formatPostDate } from "./format-post-date";
Comment thread
olliethedev marked this conversation as resolved.
Resolved
import type { SerializedPost } from "../../../types";
import { CalendarIcon } from "lucide-react";
import type { BlogPluginOverrides } from "../../overrides";
Expand Down Expand Up @@ -231,10 +231,8 @@ export function PostCard({ post }: { post: SerializedPost }) {
const Image = router?.Image ?? DefaultImage;
const basePath = useBasePath();
const blogPath = `${basePath}/blog/${post.slug}`;
const postDate = formatDate(
post.publishedAt || post.createdAt,
"MMMM d, yyyy",
);
const timestamp = post.publishedAt || post.createdAt;
const postDate = formatPostDate(timestamp);
const gradient = post.image ? null : getGradientFromTitle(post.title);

return (
Expand Down Expand Up @@ -292,7 +290,7 @@ export function PostCard({ post }: { post: SerializedPost }) {
<div className="flex flex-wrap gap-2">
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<CalendarIcon className="h-3 w-3" />
<time dateTime={postDate}>{postDate}</time>
<time dateTime={timestamp}>{postDate}</time>
</div>
{post.tags &&
post.tags.length > 0 &&
Expand Down