Skip to content

fix(jsx): render async children of document metadata tags instead of [object Promise] - #5204

Merged
yusukebe merged 1 commit into
honojs:mainfrom
pacocartones:fix/jsx-async-metadata-promise
Aug 9, 2026
Merged

yusukebe merged 1 commit into
honojs:mainfrom
pacocartones:fix/jsx-async-metadata-promise

Conversation

@pacocartones

Copy link
Copy Markdown
Contributor

The bug

When a document metadata tag (<title>, <script>, <style>, <link>, <meta>) has async children (an async component or a promise as a child), the rendered HTML contains the literal string [object Promise] instead of the tag's content.

Two symptoms, same cause:

  • Without <head> in the tree: the tag's content is lost and [object Promise] is rendered in its place.
  • With <head> in the tree: the tag is hoisted into <head> correctly, but [object Promise] is left behind in the body, because insertIntoHead tries to replaceAll(tag, '') with the resolved string while the buffer contains [object Promise], so the removal silently no-ops.

The cause

In src/jsx/intrinsic-element/components.ts, documentMetadataTag, async branch (line 111):

if (string instanceof Promise) {
  return string.then((resString) =>
    raw(string, [   // ← wraps the Promise, not the resolved string
      ...((resString as HtmlEscapedString).callbacks || []),
      insertIntoHead(tag, resString, restProps, precedence),
    ])
  )
}

raw() does new String(value), and new String(Promise.resolve('x')).toString() is "[object Promise]". The sync branch right below correctly passes the resolved string; the async branch wraps the unresolved string (the Promise) instead of resString.

The existing test only covers an async component that returns [<title>Hello</title>] — there the promise resolves before documentMetadataTag runs, so it takes the sync branch. The async branch had no coverage.

Reproduction

const AsyncChild = async () => <>{'Hello'}</>
const template = (
  <html>
    <head></head>
    <body>
      <title><AsyncChild /></title>
      <h1>World</h1>
    </body>
  </html>
)

On main (cf78528, v4.13.1), after resolveCallback(...):

WITH HEAD    >>> <html><head><title>Hello</title></head><body>[object Promise]<h1>World</h1></body></html>
WITHOUT HEAD >>> <html><body>[object Promise]<h1>World</h1></body></html>

The fix

One token — pass the resolved string to raw():

   return string.then((resString) =>
-    raw(string, [
+    raw(resString, [
       ...((resString as HtmlEscapedString).callbacks || []),
       insertIntoHead(tag, resString, restProps, precedence),
     ])
   )

The sync branch is untouched (it already uses string correctly).

Tests

Two tests added in src/jsx/intrinsic-element/components.test.tsx, mirroring the existing "async component array" test:

  • should hoist a title with async children — with <head>: asserts the exact HTML, title hoisted, body clean.
  • should render a title with async children without a head element — without <head>: asserts the content is preserved and [object Promise] does not appear.

Red→green verified: both tests fail on main without the fix ([object Promise] in the received HTML), both pass with it.

Verification

Run on Windows, Node v24.14.1, Bun 1.3.14, clone at ~/repos/hono:

  • vitest --run src/jsx — 37 files, 1945 passed, 9 skipped (full jsx suite green).
  • Full bun run test (tsc + whole Vitest suite): 4751 passed; 11 failures in src/helper/dev, src/middleware/logger and src/utils/color.test.ts, all ANSI/color-detection related and identical on pristine main on this Windows machine — pre-existing environment failures, unrelated to this change.
  • tsc -p tsconfig.spec.json — clean.
  • eslint on both touched files — clean.
  • prettier --check on both touched files — clean.

Not verified: Deno/Bun-native runtime tests (test:deno, test:bun) — the change is runtime-agnostic (pure string handling), but I only ran the Vitest suite under Node.

The author should do the following, if applicable

  • Add tests
  • Run tests
  • bun run format:fix && bun run lint:fix to format the code
  • Add TSDoc/JSDoc to document the code (no new API)

@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 79.70%. Comparing base (cf78528) to head (7e3ab07).

Files with missing lines Patch % Lines
src/jsx/intrinsic-element/components.ts 0.00% 1 Missing ⚠️

❌ Your patch check has failed because the patch coverage (0.00%) is below the target coverage (80.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5204      +/-   ##
==========================================
+ Coverage   79.68%   79.70%   +0.01%     
==========================================
  Files         155      155              
  Lines       10894    10894              
  Branches     2281     2281              
==========================================
+ Hits         8681     8683       +2     
+ Misses       2213     2211       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@usualoma

usualoma commented Aug 9, 2026

Copy link
Copy Markdown
Member

Thank you!

@yusukebe
yusukebe merged commit 765d13b into honojs:main Aug 9, 2026
20 checks passed
BlankParticle pushed a commit to BlankParticle/hono that referenced this pull request Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants