Skip to content

fix(jsx): preserve context when using await before html helper#4662

Merged
yusukebe merged 3 commits intohonojs:mainfrom
kaigritun:fix/context-await-pop
Feb 6, 2026
Merged

fix(jsx): preserve context when using await before html helper#4662
yusukebe merged 3 commits intohonojs:mainfrom
kaigritun:fix/context-await-pop

Conversation

@kaigritun
Copy link
Contributor

Summary

Fixes #4582

When an async component used await before returning html\...`with children that access context viauseContext, the context was being popped synchronously in the finally` block before the Promise resolved.

Root Cause

In src/jsx/context.ts, the createContext function uses a finally block to pop the context value after rendering children. However, when .toString() returns a Promise (async components), the finally block executes immediately before the Promise resolves, causing descendant components to lose access to the context.

Before (broken)

try {
  string = props.children?.toString()
} finally {
  values.pop() // Executes immediately, even if string is a Promise!
}

After (fixed)

try {
  string = props.children?.toString()
} catch (e) {
  values.pop()
  throw e
}

if (string instanceof Promise) {
  return string.then(
    (resString) => {
      values.pop() // Pop after Promise resolves
      return raw(resString, ...)
    },
    (e) => {
      values.pop() // Pop on rejection too
      throw e
    }
  )
} else {
  values.pop() // Sync case: pop immediately
  return raw(string)
}

Reproduction

const Parent = async (props: { children?: any }) => {
  await new Promise((r) => setTimeout(r, 1000));
  return html\`<div>${props.children}</div>\`;
};

const Child = () => {
  const c = useRequestContext(); // Error: RequestContext is not provided
  return <div>hoge</div>;
};

// This now works correctly
<Parent>
  <Child />
</Parent>

Testing

  • Added 2 regression tests for the specific scenario
  • All 1636 existing JSX tests pass

When an async component used await before returning html with children,
the context was being popped synchronously in the finally block before
the Promise resolved. This caused children to lose access to the context.

The fix delays the pop() until the Promise resolves (or rejects), ensuring
context remains available throughout the async component's execution.

Fixes honojs#4582
@codecov
Copy link

codecov bot commented Feb 5, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.43%. Comparing base (f7d272a) to head (ebde4eb).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4662   +/-   ##
=======================================
  Coverage   91.43%   91.43%           
=======================================
  Files         173      173           
  Lines       11373    11377    +4     
  Branches     3296     3298    +2     
=======================================
+ Hits        10399    10403    +4     
  Misses        973      973           
  Partials        1        1           

☔ View full report in Codecov by Sentry.
📢 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
Copy link
Member

usualoma commented Feb 6, 2026

Hi @kaigritun

Thank you for creating the pull request. It's a huge help!

I believe the proposed changes will fix the issue correctly. I'd like to reduce the number of values.pop() statements, so would it be okay to change it as follows? I think we can merge it then.

diff --git i/src/jsx/context.ts w/src/jsx/context.ts
index be4a8a99..dd32d7d3 100644
--- i/src/jsx/context.ts
+++ w/src/jsx/context.ts
@@ -30,16 +30,11 @@ export const createContext = <T>(defaultValue: T): Context<T> => {
     }
 
     if (string instanceof Promise) {
-      return string.then(
-        (resString) => {
+      return string
+        .then((resString) => raw(resString, (resString as HtmlEscapedString).callbacks))
+        .finally(() => {
           values.pop()
-          return raw(resString, (resString as HtmlEscapedString).callbacks)
-        },
-        (e) => {
-          values.pop()
-          throw e
-        }
-      )
+        })
     } else {
       values.pop()
       return raw(string)

@kaigritun
Copy link
Contributor Author

Refactored to use .finally() as suggested. The cleanup now happens in one place:

return string
  .finally(() => values.pop())
  .then((resString) => raw(resString, ...))

This removes the duplicate values.pop() calls from both the resolve and reject handlers. All context tests passing.

@usualoma
Copy link
Member

usualoma commented Feb 6, 2026

Hi @kaigritun

Thanks for the changes. I think it's good.

It seems package-lock.json was added by mistake, so please delete it.

@kaigritun
Copy link
Contributor Author

Done, removed package-lock.json. Thanks for the review!

Copy link
Member

@usualoma usualoma left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

Copy link
Member

@yusukebe yusukebe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@yusukebe
Copy link
Member

yusukebe commented Feb 6, 2026

@kaigritun @usualoma Thank you!

@yusukebe yusukebe merged commit cea7b7b into honojs:main Feb 6, 2026
20 checks passed
@MichaelDeBoey
Copy link

I'm not making any claims about the quality of their work, but I wanted to let you know that @kaigritun is a fully-autonomous non-human actor
https://socket.dev/blog/ai-agent-lands-prs-in-major-oss-projects-targets-maintainers-via-cold-outreach

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.

useRequestContext not working when using await and the html helper together

4 participants