Skip to content

Conversation

@charliecreates
Copy link
Contributor

Component / Package Name:

jsx-email

This PR contains:

Head-scoped MSO conditionals now use the standard closer (<![endif]-->, without the slash) while keeping the existing <![endif]/--> closer for conditionals rendered outside <head>.

  • bugfix
  • feature
  • refactor
  • documentation
  • other

Are tests included?

  • yes (bugfixes and features will not be merged without tests)
  • no

Breaking Changes?

  • yes (breaking changes will not be merged unless absolutely necessary)
  • no

If yes, please include "BREAKING CHANGES:" in the first commit message body, followed by a description of what is breaking.

List any relevant issue numbers:

resolves #403

Description

This is a targeted fix for Classic Outlook’s conditional-comment parsing: MSO conditional blocks that render inside <head> now emit the standard <![endif]--> closer.

The existing <![endif]/--> closer remains in place for non-head conditionals to preserve the behavior introduced in #330.

Note: per the maintainer request on #403, I did not run tests/validation as part of opening this PR.

Copy link
Contributor Author

@charliecreates charliecreates bot left a comment

Choose a reason for hiding this comment

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

The core behavior change looks targeted and well-covered, but rendersInHeadScope is currently keyed to headEl && toHead, which may create surprising closer selection when head is requested but a <head> root isn’t present. The new tests rely on a regex-based <head> extraction and could be hardened to better guarantee the conditional was actually relocated into <head> (not just that the head slice lacks the slashed closer). Addressing these would make the behavior more intentional and reduce brittleness.

Additional notes (3)
  • Maintainability | packages/jsx-email/src/renderer/conditional.ts:60-60
    rendersInHeadScope is coupled to the relocation logic (as your comment notes), but using Boolean(headEl && toHead) means the closer depends on whether a <head> root exists, not strictly whether the conditional will ultimately be rendered in <head>.

That can produce a surprising behavior split:

  • head prop set but no headEl (e.g., fragment render / partial document) ⇒ you’ll emit the non-head closer even though the caller asked for head.

If the intent is “use the standard closer whenever the component is declared as head-scoped”, keying off toHead alone is more consistent. If the intent is “only use the standard closer when we actually relocate into <head>”, then this is OK but should be guarded by a regression test that demonstrates the no-headEl case (so the behavior is deliberate and stable).

  • Maintainability | packages/jsx-email/src/renderer/conditional.ts:60-60
    rendersInHeadScope is now the pivot for whether the closer is <![endif]--> vs <![endif]/-->. As written, it’s derived from headEl && toHead, but the code comment says it “must stay in sync with the relocation logic”. That’s a brittle coupling: if relocation logic changes (e.g., conditionals are kept in place inside <head> even without a “head root”, or the plugin begins constructing a <head> later), the closer choice could silently diverge from where the nodes actually land.

Given this is a correctness fix for Outlook parsing, it would be safer to base the closer choice on the node’s actual location at emission time rather than on a flag that approximates relocation intent.

  • Readability | packages/jsx-email/test/conditional-endif-closer.test.tsx:6-9
    The helper getHead() parses HTML with a non-trivial regex. In this suite you only need to assert that the head-scoped conditional produced the correct closer in the head portion; but as written, if <head> appears in an attribute value or malformed HTML ever sneaks in, this could produce misleading slices.

Given the renderer is deterministic, a simpler and safer approach is to split on </head> and take the prefix, or assert relative ordering (opener before closer) within that prefix.

Summary of changes

What changed

jsx-email MSO conditional closer behavior

  • Updated MSO conditional rendering to choose the closer based on head-scope:
    • Inside <head>: uses the standard closer <![endif]-->
    • Outside <head>: keeps the existing Outlook-friendly closer <![endif]/-->
  • Introduced a rendersInHeadScope flag derived from headEl && toHead to keep closer selection aligned with the existing relocation logic.

Tests & snapshots

  • Added a dedicated test suite (packages/jsx-email/test/conditional-endif-closer.test.tsx) covering:
    • head vs non-head closers
    • expression-based conditionals
    • the OfficeDocumentSettings <xml> case
  • Updated multiple snapshots to reflect the slashless closer in <head> output.

Files touched

  • Source: packages/jsx-email/src/renderer/conditional.ts
  • Tests: packages/jsx-email/test/conditional-endif-closer.test.tsx
  • Snapshot updates across render/debug/tailwind suites.

@charliecreates charliecreates bot removed the request for review from CharlieHelps January 21, 2026 20:48
@shellscape
Copy link
Owner

@CharlieHelps

rendersInHeadScope is coupled to the relocation logic (as your comment notes), but using Boolean(headEl && toHead) means the closer depends on whether a root exists, not strictly whether the conditional will ultimately be rendered in .

How can we improve that?

@charliecreates
Copy link
Contributor Author

Reviewing the new comment on PR #404 and getting ready to respond in the context of the existing changes.

Updated: Jan 21, 21:04 UTC
I won’t see replies while I’m working on this and I can’t pause or stop until it’s finished.

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Jan 21, 2026

Expand this to see my work.
  • Re-ran node_modules/.bin/moon run jsx-email:test.ci under Node 18 after tightening the rendersInHeadScope logic in packages/jsx-email/src/renderer/conditional.ts, confirming all 44 files / 191 tests still pass with only the expected defineConfig and <Row> warnings (from test/config/define-config.test.ts and test/row.test.tsx).
  • Re-ran pnpm moon run jsx-email:tsc for packages/jsx-email under Node 18 and saw TS5055 "Cannot write file ... because it would overwrite input file" errors for src/components/*.d.ts, indicating tsc was trying to emit declarations into packages/jsx-email/src/components where checked-in .d.ts files already exist.
  • Verified tracked .d.ts files such as packages/jsx-email/src/components/background.d.ts and packages/jsx-email/src/components/body.d.ts, confirming the TS5055 errors are configuration-related and not caused by the conditional rendering changes.
  • Pushed the updated rendersInHeadScope and MSO-closer behavior in packages/jsx-email/src/renderer/conditional.ts plus the matching expectations in packages/jsx-email/test/conditional-endif-closer.test.tsx to the ai-403-mso-conditional-comments-in-head-use-non-st branch backing PR #404.
  • Refined the implementation around htmlEl, headEl, toHead, and rendersInHeadScope in packages/jsx-email/src/renderer/conditional.ts to drop <head> auto-synthesis while keeping the MSO conditional-closer fix scoped and preserving existing head-only behavior where appropriate.
  • Cleaned packages/jsx-email/src with git clean -fd to remove previously emitted .d.ts/.js artifacts so pnpm moon run jsx-email:tsc could run against a truly clean tree.
  • Re-ran pnpm moon run jsx-email:tsc under Node 18 after the clean, confirmed it now completes successfully (1 task, ~4s) with only a benign warning about a missing packages/jsx-email/app input, and no TS5055 errors.
  • Checked git status to confirm the only tracked source change is packages/jsx-email/src/renderer/conditional.ts, with new src/components/*.d.ts/.js artifacts appearing solely as untracked build outputs.
  • Committed the focused change to packages/jsx-email/src/renderer/conditional.ts as fix(jsx-email): avoid synthesizing <head> root (074b800b), leaving generated src/components artifacts untracked so CI can manage them.
  • Confirmed via git status --porcelain that the working tree on ai-403-mso-conditional-comments-in-head-use-non-st is clean, and verified the current HEAD SHA 074b800b58de34632240c3f48d767d499827560b matches the commit linked in the PR context.

@charliecreates
Copy link
Contributor Author

#404 (comment)

Improvement I went with: treat head/data-head as the contract for closer selection, instead of keying that decision off whether a <head> node exists in the parsed tree. That removes the “closer depends on <head> presence” coupling you called out, while still keeping relocation behavior unchanged.

Implemented in 074b800brendersInHeadScope is now toHead in packages/jsx-email/src/renderer/conditional.ts.

Notes
  • Relocation stays as-is: we still only move into <head> when toHead && headEl is true (so no structural surprises).
  • If you’d rather have the closer follow “actual relocation happened” instead, we can revert rendersInHeadScope back to Boolean(headEl && toHead), but that reintroduces the original coupling.
Verification
$ pnpm install

# Build artifacts some suites rely on
$ pnpm moon run jsx-email:build
$ pnpm moon run plugin-inline:build plugin-minify:build plugin-pretty:build

# Lint (0 errors; warnings only)
$ pnpm moon run repo:lint

# Tests (Node 18.20.8)
$ PATH="$HOME/.local/share/mise/installs/node/18.20.8/bin:$PATH" node_modules/.bin/moon run jsx-email:test.ci
# Test Files 44 passed; Tests 191 passed

# TypeScript (emits into src; run from clean state)
$ git clean -fd packages/jsx-email/src
$ PATH="$HOME/.local/share/mise/installs/node/18.20.8/bin:$PATH" pnpm moon run jsx-email:tsc
# exit 0

@shellscape
Copy link
Owner

@CharlieHelps what if a use puts a conditional within head manually but doesn't use the head or data-head properties?

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Jan 22, 2026

Expand this to see my work.
  • Reviewed tests in packages/jsx-email/test/tailwind/tailwind.test.tsx, packages/jsx-email/test/render/fixtures/preview.tsx, and packages/jsx-email/test/conditional-endif-closer.test.tsx to confirm how manually authored <head> elements and MSO conditionals behave inside vs. outside head scope.
  • Updated packages/jsx-email/src/renderer/conditional.ts so rendersInHeadScope is derived from both toHead and whether the parent node tag is head, ensuring correct MSO closers when a Conditional is physically nested under a manual <head> even without data-head.
  • Reconciled the mental model in the reply to clearly separate logical head scope via head/data-head from actual DOM placement under <head>, explaining that Conditional works with a plain <head> and does not require a special Head component.
  • Confirmed from a fresh pnpm moon run jsx-email:test.ci run that remaining failures are unrelated to Conditional’s head-scoping logic, so the explanation can focus on the intended behavior rather than suite flakiness.
  • Ran pnpm moon run jsx-email:build and the plugin builds (plugin-inline:build, plugin-minify:build, plugin-pretty:build) to ensure the explanation matches the built artifacts’ behavior.
  • Used a focused bun script with render, Conditional, and Raw to inspect the HTML for a manual <head><Conditional mso>...</Conditional></head> case, verifying the actual output comment and closer format in the latest code.
  • Tightened the comment text for PR #404 to explicitly answer shellscape’s question: hand-written <head> is supported, Conditional inside it still participates in head scoping, and the non-standard closer behavior is tied to that scope rather than the presence of data-head.
  • Reverted experimental tweaks to packages/jsx-email/src/renderer/conditional.ts and packages/jsx-email/test/conditional-endif-closer.test.tsx with git restore after confirming the HTML parser rehomes unknown elements from <head>, which makes manual-head detection unreliable.
  • Inspected and then restored changes in packages/plugin-inline/package.json, packages/plugin-minify/package.json, and packages/plugin-pretty/package.json so build-time exports.source and module additions are not committed, keeping the branch focused on Conditional behavior.
  • Re-ran git status --porcelain to verify the working tree is clean before finalizing the GitHub reply, ensuring the written explanation matches the current committed code for Conditional.

@charliecreates
Copy link
Contributor Author

#404 (comment)

If someone “manually” nests <Conditional mso> under a <head> tag but doesn’t set head / data-head, it won’t behave like a head-scoped conditional.

Reason: <Conditional> renders a placeholder <jsx-email-cond …> element, and during HTML parsing that tag isn’t considered valid head content, so it typically gets hoisted out of <head> into <body> before our rehype plugin even runs. Since data-head is missing, the conditional plugin won’t relocate it back into <head> (if (toHead && headEl) …), and it’ll use the non-head closer (<![endif]/-->). See toHead gating both closer selection and relocation in packages/jsx-email/src/renderer/conditional.ts (around the toHead / rendersInHeadScope block and the if (toHead && headEl) branch).

So the rule of thumb is: if you want a conditional to actually land in <head> (and get the standard head closer), you need to opt into head-scope explicitly via head / data-head — even if your JSX placement is “in head already”.

@shellscape
Copy link
Owner

@CharlieHelps is that documented on the website?

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Jan 22, 2026

Expand this to see my work.
  • Confirmed apps/web is a VitePress site (via apps/web/package.json), so component docs like Conditional are served from that app.
  • Located apps/web/src/docs/components/conditional.md and verified its frontmatter (title: 'Conditional', slug: conditional) plus examples importing Conditional and Head from jsx-email.
  • Verified that the VitePress config in apps/web/.vitepress/config.mts uses srcDir: 'src' and base: '/', so the Conditional docs resolve under /docs/components/conditional.
  • Reviewed apps/web/.vitepress/sidebar.mts and confirmed it auto-generates sidebar links like /docs/components/conditional from src/docs/components/conditional.md, meaning the Conditional page is live on the website at /docs/components/conditional.
  • Rechecked apps/web/src/docs/components/conditional.md to confirm it documents Conditional props (expression, head, mso) but does not mention the special MSO closing-tag behavior for head conditionals implemented in getConditionalPlugin.
  • Refined the reply draft for PR #404 to say: yes, Conditional is documented on the site at /docs/components/conditional, but the specific MSO head-conditional closer behavior is not yet documented and should be added there.

@charliecreates
Copy link
Contributor Author

#404 (comment)

Partially. The website does have a Conditional docs page (https://jsx.email/docs/components/conditional) and it mentions the head prop, but it doesn’t currently spell out the nuance we just discussed: JSX placement under a literal <head> / <Head> isn’t reliable on its own because the intermediate <jsx-email-cond> tag can get hoisted out of <head> during HTML parsing, so you need to opt into head-scope via head / data-head to guarantee it lands in <head> (and gets the head closer behavior).

If you want, I can follow up with a small docs tweak in docs/components/conditional.md to (1) include head?: boolean in the props snippet and (2) adjust the example / add a short note about the hoisting behavior.

@shellscape
Copy link
Owner

@CharlieHelps yes please update the docs

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Jan 22, 2026

Expand this to see my work.
  • Reviewed the Head implementation in packages/jsx-email/src/components/head.tsx to confirm how it wraps MSO-specific <Raw> XML with <Conditional head mso>.
  • Updated the usage example in apps/web/src/docs/components/conditional.md (symlinked to docs/components/conditional.md) so the Head example now uses <Conditional head mso={true}> in line with PR #404.
  • Extended the ConditionalProps snippet in conditional.md to document the new head?: boolean; prop alongside expression and mso.
  • Documented that Conditional renders an intermediate <jsx-email-cond> element and that head / data-head should be used when the conditional must reliably land in <head>.
  • Added a clarification in conditional.md that, for mso conditionals, head-scoped blocks close with <![endif]--> while non-head MSO blocks keep the <![endif]/--> closer.
  • Verified the final docs/components/conditional.md content via sed to ensure the new head/MSO description reads cleanly and matches the implementation.
  • Committed the docs change as docs(jsx-email): clarify Conditional head scoping (9abc3da4) after lint-staged/prettier passed on the modified markdown.
  • Pushed the updated branch ai-403-mso-conditional-comments-in-head-use-non-st so PR #404 now includes the clarified Conditional docs shellscape requested.

@charliecreates
Copy link
Contributor Author

#404 (comment)

Pushed a docs update to the Conditional page in 9abc3da4 (file: docs/components/conditional.md).

What changed
  • Updated the example to explicitly use head for MSO-in-<head> (<Conditional head mso>).
  • Added head?: boolean to the ConditionalProps snippet.
  • Added a short note explaining why “manual” placement under a literal <head> isn’t reliable (the intermediate <jsx-email-cond> element can get hoisted during HTML parsing) and that head / data-head is the opt-in to guarantee head placement.
  • Noted the head-scoped MSO closer behavior (<![endif]--> in head vs <![endif]/--> outside head).

@lordelogos lordelogos marked this pull request as ready for review January 24, 2026 11:43
@lordelogos lordelogos added the bug Something isn't working label Jan 24, 2026
@shellscape
Copy link
Owner

@CharlieHelps our collaborator pushed an update which is a significant departure from the general context of this PR initially. He did some research about this and feels the slash is not needed. One of the reasons he cites is MJML not rendering any conditional comments with the slash-hack. He cites mjmlio/mjml#1284 as evidence of rendered output not containing the slash.

Don't write any code, but research the following:

  1. Deep dive the MJML documentation, github issues, github commits, and github code to look for any use of the slash-hack.
  2. Look for MJML commits which may have added or removed the slash hack, and if removed provide the reasoning for its removal
  3. Search the web for mentions of the slash hack and their application, discussion, validity

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Jan 24, 2026

Expand this to see my work.
  • Reviewed MJML’s mergeOutlookConditionnals helper at packages/mjml-core/src/helpers/mergeOutlookConditionnals.js and confirmed the regex explicitly matches <![endif]--> (no slash) followed by <!--[if mso | IE]>, reinforcing that MJML’s post-processing assumes the non-slashed closer.
  • Inspected the skeleton helper in packages/mjml-core/src/helpers/skeleton.js and verified the <head>-level conditional blocks (for <!--[if !mso]><!--> and <!--[if mso]> / <![endif]-->) all use the standard <![endif]-->/<!--<![endif]--> patterns with no slash-hack in the document shell.
  • Connected the skeleton and mergeOutlookConditionnals helpers to show that both MJML’s layout skeleton and its conditional-merging logic are internally consistent around non-slashed Outlook conditionals, which would likely break if a slash-hack closer were introduced.
  • Integrated these internal MJML findings into the narrative for PR #404, strengthening the case that <![endif]/--> is not just absent from output but incompatible with MJML’s own helper assumptions.
  • Searched MJML’s git history with -S'<![endif]-->' and --follow on mergeOutlookConditionnals.js, confirming that since its introduction in commit d9e2c753 the helper has always used the non-slashed <![endif]--> pattern, with no historical add/remove of a slash-hack variant.
  • Reviewed the full diff for d9e2c753 and confirmed the commit message (“style attribute improve, mj-social new markup, merge outlook comments”) and tests in packages/mjml-core/tests/mergeOutlookConditionnals-test.js document merging adjacent standard Outlook conditionals but never mention or test <![endif]/-->, implying the slash-hack was never part of MJML’s intended model.
  • Spot-checked another email framework (Foundation for Emails, zurb/foundation-emails) using code search for both <![endif]/--> and <![endif]-->, finding usages of the standard closer but no hits for the slash-hack, which supports that mainstream email frameworks don’t emit or rely on the hacked closer.

@charliecreates
Copy link
Contributor Author

#404 (comment)

I dug through MJML’s code + git history and couldn’t find any instance of the <![endif]/--> “slash-hack”. MJML consistently emits the standard closer <![endif]--> (and <!--<![endif]--> for the downlevel-revealed !mso form), including in <head> (e.g. the OfficeDocumentSettings XML block). The closest “related” thing I found is that MJML has a helper to merge adjacent conditional boundaries (<![endif]--><!--[if mso | IE]>) rather than changing the closer.

1) MJML code: any use of the slash-hack?

No. In MJML core, the closer is defined as the standard <![endif]-->:

MJML’s default HTML skeleton (<head> in particular) also uses the standard closers:

And the “adjacent comments” problem is handled via merging (regex cleanup) — still using the standard closer:

2) MJML commits that added/removed the slash-hack?

I couldn’t find any evidence MJML ever had it.

  • Repo-wide search for <![endif]/-->: no matches.
  • History search: git log --all -S'<![endif]/-->' came back empty (no “add” commit, no “remove” commit, no rationale).

For additional confidence: the commit that introduced the conditionalTag helper already used the standard closer:

  • a5f0f23777ec9a5cf1139ebbb271192439148b6c
    (file content at that commit shows endConditionalTag = '<![endif]-->')
    mjmlio/mjml@a5f0f23

The “merge adjacent conditionals” helper was added in:

(That’s the closest I saw to an explicit “we had conditional-comment boundary issues, here’s how we mitigated them”.)

3) Web mentions of the slash-hack (application / discussion / validity)

I searched for the literal string <![endif]/--> and for “slash hack” in the HTML email + Outlook conditional-comment context and didn’t find reputable public guidance using it.

What is widely documented is the standard syntax (no slash):

The issue your collaborator cited (mjmlio/mjml#1284) is also not about the slash-hack — it’s about the downlevel-revealed <!--[if !mso]><!--> pattern and the <!--<![endif]--> close, and it links out to the Email on Acid writeup explaining why that pattern exists:
mjmlio/mjml#1284
https://www.emailonacid.com/blog/article/email-development/prevent-ie-edge-meta-tag-from-breaking-images-in-live-mail/

Net: MJML is good evidence that you can ship without the slash-hack (they do), but it doesn’t provide a “they removed it because…” story — it looks like MJML never used it, and instead dealt with “conditional adjacency” via merge/cleanup.

@shellscape
Copy link
Owner

@CharlieHelps testing your beta status

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MSO conditional comments in <head> use non-standard <![endif]/--> closing that breaks Classic Outlook

3 participants