feat(comments): opt-in live comment display (<Comments live> / <CommentForm live>)#1903
Conversation
Visitors currently can't see their own comment after posting without a page reload. Add an opt-in `live` prop that inserts the new comment client-side on success, and render comments awaiting moderation in a distinct muted state so they never look publicly visible. - POST /comments response gains an additive `comment` payload (id, author info, body, createdAt, status) alongside the existing id/status/message fields. - <CommentForm live> dispatches an `ec:comment:created` DOM event with that payload on success. - <Comments live> listens for the event and inserts the comment, respecting the active sort (a new, zero-reaction comment always belongs at the end under both "oldest" and "best" — proven inline). - Zero behavior change when `live` is unset on either component. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 05e163c The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Scope checkThis PR changes 520 lines across 9 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
|
This PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity. If you're still working on this, please push an update or leave a comment. |
|
Discussion #1497 got the "Approved for PR" label on 2026-07-11 — missed that. Marking ready for review now; code/tests/checks were already green since the original commit. |
There was a problem hiding this comment.
This is the right change for the problem it describes: visitors currently have to reload to see a comment they just posted, and auto-approved comments are invisible to their own author until then. The opt-in live prop design mirrors the existing reactions pattern, keeps JS zero-cost unless enabled, and the API change is purely additive. I checked the diff, the changed route/schema/components/tests, and the surrounding comment repository, query helper, object-cache, and fixture code.
Headline: the implementation is clean, backwards-compatible, and introduces no new logged-out database queries. There are no security issues in the client-side rendering path (output is escaped before innerHTML, and the inline script is a static constant). The main code-level concerns are test quality and a couple of comment/logic nits.
Before merge, the linked Discussion #1497 still needs the maintainer Approved for PR label per CONTRIBUTING.md/AGENTS.md — the author already flagged this, but it is a real blocker.
Note: the author also flagged a pre-existing bug where CommentForm reads result.message instead of result.data.message; that is out of scope and not treated as a new finding here.
| it("does not emit live-display markup or script by default (backward compatible)", async () => { | ||
| const html = await fetchHtml(ctx, "/posts/first-post"); | ||
| expect(html).not.toContain("data-ec-live"); | ||
| expect(html).not.toContain("ec:comment:created"); | ||
| }); | ||
|
|
||
| it("emits live-display markup and script only when `live` is set", async () => { | ||
| const html = await fetchHtml(ctx, "/posts-live/first-post"); | ||
|
|
||
| // Opt-in markers on both <Comments> and <CommentForm> | ||
| expect(html).toContain("data-ec-live"); | ||
| expect(html).toContain("data-ec-threaded"); | ||
|
|
||
| // Inline script wires up the created-comment event and renders the | ||
| // moderation-pending case in a visually distinct, muted state. | ||
| expect(html).toContain("ec:comment:created"); | ||
| expect(html).toContain("ec-comment-pending"); | ||
| expect(html).toContain("Awaiting moderation"); |
There was a problem hiding this comment.
[needs fixing] These two tests only assert that implementation-detail strings appear in the HTML (data-ec-live, ec:comment:created, ec-comment-pending, Awaiting moderation). That cannot catch a real regression in the actual behavior: the event listener could be wired to the wrong selector, the detail payload could be malformed, the inserted DOM could be missing classes, etc. Per AGENTS.md, tests that assert implementation details straight back "inflate coverage and pins intentional changes" and should be rewritten against observable behavior or deleted.
Replace these HTML substring checks with a real browser evaluation (e.g., Playwright under tests/e2e/) that submits a comment on /posts-live/first-post and asserts the new row appears in the DOM without a reload.
| // Progressive-enhancement script for live/optimistic display. Emitted | ||
| // verbatim (is:inline) only when `live` is enabled, so pages that don't opt | ||
| // in ship zero extra JS. Listens for the `ec:comment:created` event that a | ||
| // sibling <CommentForm live> dispatches on successful submission, and | ||
| // inserts the new comment client-side — respecting the active sort. Both | ||
| // "oldest" and "best" (Wilson-ranked) place a brand-new, zero-reaction | ||
| // comment at the very end of its list: chronologically it's the newest, and | ||
| // under Wilson ranking a zero-reaction comment scores exactly 0 (the | ||
| // minimum), tying with any other zero-reaction comments and losing every | ||
| // oldest-first tiebreak against them since it's the newest of the group. So | ||
| // "append at the end" is correct for both sorts — no client-side ranking | ||
| // math needed. Replies always stay chronological (per the SSR list), so | ||
| // appending to the end of the parent's reply list is correct there too. | ||
| // Mirrors the escaping/autolink logic in formatBody() above (duplicated, | ||
| // not imported — this runs in the browser). |
There was a problem hiding this comment.
[suggestion] This comment block is mostly justifying the duplication and the append-at-end strategy. AGENTS.md asks that comments be evergreen for future readers and avoid narrating rejected alternatives / justifying decisions. Trim it to a single line that states the bare fact.
| // Progressive-enhancement script for live/optimistic display. Emitted | |
| // verbatim (is:inline) only when `live` is enabled, so pages that don't opt | |
| // in ship zero extra JS. Listens for the `ec:comment:created` event that a | |
| // sibling <CommentForm live> dispatches on successful submission, and | |
| // inserts the new comment client-side — respecting the active sort. Both | |
| // "oldest" and "best" (Wilson-ranked) place a brand-new, zero-reaction | |
| // comment at the very end of its list: chronologically it's the newest, and | |
| // under Wilson ranking a zero-reaction comment scores exactly 0 (the | |
| // minimum), tying with any other zero-reaction comments and losing every | |
| // oldest-first tiebreak against them since it's the newest of the group. So | |
| // "append at the end" is correct for both sorts — no client-side ranking | |
| // math needed. Replies always stay chronological (per the SSR list), so | |
| // appending to the end of the parent's reply list is correct there too. | |
| // Mirrors the escaping/autolink logic in formatBody() above (duplicated, | |
| // not imported — this runs in the browser). | |
| // Client-side replica of the SSR rendering; emitted verbatim as an is:inline script. |
| } | ||
|
|
||
| function renderComment(section, comment, isReply) { | ||
| const pending = comment.status !== 'approved'; |
There was a problem hiding this comment.
[suggestion] comment.status !== 'approved' treats any non-approved status as pending, including spam. The POST endpoint can return spam when a moderation hook classifies the comment, so a spam submission would be rendered as "Awaiting moderation — visible only to you", which is misleading.
Narrow the condition to status === 'pending' and decide what to do with spam/trash (most likely: don't render it to the author at all, since the success message already appears in the form).
| @@ -1112,7 +1113,7 @@ const commentsPaths = { | |||
| description: "Comment created (pending moderation)", | |||
There was a problem hiding this comment.
[suggestion] The 201 response can now be approved when comments are auto-approved (commentsModeration: "none"), so the description "Comment created (pending moderation)" is no longer accurate.
| description: "Comment created (pending moderation)", | |
| description: "Comment created", |
Addresses review on emdash-cms#1903: - comment.status !== 'approved' treated a spam-classified submission as merely pending, rendering it with "Awaiting moderation" instead of hiding it. renderComment now bails out entirely for spam/trash status. - Trimmed reviewer-facing narrative from the test file header and the LIVE_SCRIPT comment block, per AGENTS.md comment discipline. - Fixed the stale "Comment created (pending moderation)" OpenAPI description — the 201 response can be `approved` under commentsModeration: "none". - Replaced the two implementation-detail-string integration tests with a Playwright E2E spec (e2e/tests/comments-live.spec.ts) that submits a real comment on /posts/first-post and asserts it appears in the DOM without a reload. Note: could not execute the new Playwright spec locally — this Windows dev environment hits a pre-existing, unrelated `spawn astro ENOENT` in both e2e/global-setup.ts and the vitest client-integration harness (node's spawn() doesn't resolve .cmd shims without shell:true on Windows). Confirmed it's pre-existing by running an untouched spec (redirects.spec.ts), which fails identically. CI runs on Linux and should execute it fine. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Addressed the code-level review items:
One caveat: I couldn't execute the new Playwright spec locally — this dev environment is Windows, and both The Discussion #1497 "Approved for PR" label is still outstanding and outside what I can do here. |
Overlapping PRsThis PR modifies files that are also changed by other open PRs: This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
What does this PR do?
Draft — opening for review-readiness, not yet requesting merge. This implements the "optimistic comment display" increment proposed in #1497 (comment) (the Tier 2/3 follow-up to the reactions RFC, posted 2026-06-23, bumped 2026-07-02). Per CONTRIBUTING.md, features need an Ideas Discussion +
Approved for PRlabel before merge — that label isn't on the discussion yet, so this stays draft until it is. Opening it now so the proposal has working code behind it rather than staying abstract, same as how the reactions Tier 1 branch was built ahead of its approval.Today, on a successful comment submission the form only clears the textarea — the SSR comment list never updates, so a visitor can't see the comment they just posted without a manual reload. Worse when
commentsModeration: "none"(auto-approve): the comment is live server-side immediately, but invisible to its own author until reload./_emdash/api/comments/:collection/:contentIdnow additionally returns the createdcommentpayload on success (additive field only — existingid/status/messageconsumers unaffected).liveprop on<CommentForm>and<Comments>. When both are set, a small inline script (mirrors the existingreactionsprop's script pattern) listens for the new comment and inserts it client-side, respecting the active sort — a fresh zero-reaction comment always sorts last under botholdestand Wilsonbestranking, so no client-side ranking math is needed.liveisn't set.Noticed but did not fix (out of scope, flagging for a separate small PR):
CommentForm's inline script readsresult.messageon success, but the success envelope is{ data: { message, ... } }, so it always falls back to the generic "Comment submitted!" text instead of the server's actual message. Only the success path is affected.Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (targeted:comments-submit-response.test.ts,comments-rate-limit.test.ts,reactions.test.ts, openapi tests — all green; the full-dev-servercomments.test.tsE2E suite is pre-existing CI-only per its own vitest config, verified unrelated to this diff)pnpm formathas been runAI-generated code disclosure