fix(jsx-email): implement Conditional via rehype (no regex); preserve <Raw>; forbid nested <Conditional>#324
Conversation
…; forbid nested <Conditional>; support head MSO via Raw; update tests and snapshots
…altering existing snapshots; add render-mode context; limit cond plugin to marker nodes; revert head/test changes
… <Head> MSO AllowPNG to self-closing form\n\n- conditional.ts: drop duplicated Raw/ParentWithRaw definitions and import from ./raw\n- raw.ts: export Raw and ParentWithRaw for reuse\n- head.tsx: restore <o:AllowPNG /> per review\n\nNo snapshot or test changes.
…r(); delete mode.ts\n\n- Provider became redundant after switching <Conditional> to raw-detection without context\n- Remove import and wrapper; call jsxToString(component) directly\n- Fix type narrowing in hasRaw() to satisfy TS under strict checks
…tory @ts-ignore note in <Conditional>
…acy jsxToString path per review
…; use hast-util-to-html after Raw plugin - Runs after Raw plugin, so `<jsx-email-raw>` children are preserved verbatim - Avoids spinning up a rehype processor per node; lighter and faster - No behavior change intended; only internal implementation of child serialization
…owDangerousCharacters)
…k only\n\n- Remove child serialization and directive handling; legacy path already wraps and cleanup strips wrappers\n- Keep only the nested <Conditional> guard and short-circuit traversal via EXIT\n- Drop unused imports/types
…atch main per review
…rop from packages/jsx-email/package.json per review\n- Update pnpm-lock.yaml to prune importer entry
…323, lockfile changes are not allowed in this PR.
… rehype plugin\n\n- Drop tag-stripping regex in render.ts; rely on plugins instead\n- Enhance conditional plugin to forbid nesting and unwrap wrapper nodes\n- Process unwrapping in descending index per parent to avoid index invalidation
…ess by parent in descending index\n\n- Replace <jsx-email-raw> with HAST raw node when possible; fallback to unwrapping children\n- Group by parent and splice in descending index order to avoid index invalidation\n- Align conditional plugin types to use ParentWithRaw for splicing
…vert type exports per review; plugin no longer consumes them\n- Add local ParentWithChildren type in conditional plugin to satisfy TS when unwrapping\n- No runtime behavior changes
…Raw to legacy output Per maintainer request: only adjust the jsxToString expectation; no other files or snapshots changed.
…and module field for better ESM/bundler support - Add "source" entries under exports.import and exports.require to point to ./src/index.ts - Add "module": "./dist/esm/index.js" to align with ESM-aware bundlers and tooling - No runtime behavior change; improves tooling, resolution, and source mapping for the three plugin packages
…t; update render.ts\n\n- switch getConditionalPlugin to sync factory with static unist-util-visit import\n- remove await at call site in render.ts\n- satisfy lint by returning CONTINUE in visitor and using expression-bodied arrow\n- narrow parent children access via typed alias instead of double-cast
…omment+placeholder into a raw conditional block - conditional plugin: when a <jsx-email-cond> contains a single comment child with an inner <jsx-email-raw><!--...--></jsx-email-raw> placeholder, replace it with a single HAST raw node: `<!--[if <expr>]>…<![endif]-->` using the unescaped payload - keep nested-<Conditional> guard and default unwrap behavior for other cases - no package.json/lockfile changes test: update minimal render snapshot to expect the conditional comment wrapper
…mment shape via indexOf/slices\n\n- replace RegExp parsing with deterministic string scanning (no regex)\n- preserve downlevel-revealed form for !mso\n- avoid unsafe casts by widening children type for raw splice\n- add dev-time warning in Raw plugin fallback when comment child missing\n\nNo package.json or lockfile changes.
There was a problem hiding this comment.
- Nested-conditional detection is incomplete: when the outer
<Conditional>renders as a single comment node (e.g.,mso={true}), inner<Conditional>s are stringified into comment text and bypass the rehype guard, contradicting the stated “forbid nested” behavior. - The nested-check in the conditional plugin performs redundant traversals, which can be simplified to a single DFS that carries an
inCondflag. - The “group-by-parent then splice descending” logic is duplicated in two plugins and should be factored into a shared helper to reduce maintenance risk.
- The legacy conditional parsing looks solid and avoids regex; no issues found with the
rawpayload preservation path for themsocase; the!msodownlevel-revealed case is correctly handled via unwrap + prior raw lifting.
Additional notes (3)
- Maintainability |
packages/jsx-email/src/components/conditional.tsx:52-53
The nested-conditional guard only runs at the rehype stage by scanning for child<jsx-email-cond>elements. When the outer<Conditional>renders as a single comment wrapper (e.g.,mso={true}), any inner<Conditional>is already stringified into the comment text and won’t exist as an element in the HAST. That means the current guard won’t fire for this common case, and nested usage will slip through—contradicting the PR’s stated guarantee that nested<Conditional>is disallowed.
Add a React-level pre-serialization check here (before jsxToString) to catch nested <Conditional> in all cases (including comment-only shapes).
-
Maintainability |
packages/jsx-email/src/renderer/raw.ts:53-64
The “group-by-parent then splice in descending index order” pattern is duplicated here and inrenderer/conditional.ts. This repetition makes future fixes easy to miss in one location and hardens maintenance. -
Maintainability |
packages/jsx-email/src/renderer/raw.ts:80-87
Nice improvement to splice by parent in descending index order. As a small enhancement for debuggability, consider including node position info (if available) in the warning to aid users in locating the problematic<Raw>wrapper during development.
Summary of changes
- components/conditional.tsx
- Minor refactor: reordered prop destructuring, removed unnecessary fragment around
Suspense. - Preserved legacy
jsxToStringpath for children serialization and retained comment/string helpers.
- Minor refactor: reordered prop destructuring, removed unnecessary fragment around
- renderer/conditional.ts
- Added a rehype plugin to unwrap
<jsx-email-cond>wrappers and forbid nested conditionals. - Implemented
parseLegacyConditionalto convert the legacy single-comment placeholder shape into arawnode, preserving<Raw>payload verbatim. - Grouped splices by parent and applied in descending index order to avoid index invalidation.
- Added a rehype plugin to unwrap
- renderer/raw.ts
- Aligned mutation strategy with conditional plugin: group by parent and splice in descending index. Added a dev-time fallback warning when the expected comment child is missing.
- renderer/render.ts
- Integrated
getConditionalPlugin()after theRawplugin; removed regex-based<jsx-email-cond>cleanup.
- Integrated
- tests
- Updated snapshot to assert that the final HTML preserves the
<Raw>content inside the MSO conditional block.
- Updated snapshot to assert that the final HTML preserves the
| const hasNestedConditional = (el: Element): boolean => { | ||
| const children = (el.children || []) as Content[]; | ||
| for (const c of children) { | ||
| if (isElement(c)) { | ||
| if (c.tagName === 'jsx-email-cond') return true; | ||
| if (hasNestedConditional(c)) return true; | ||
| } | ||
| } | ||
| return false; | ||
| }; | ||
|
|
||
| const walk = (parent: Parents) => { | ||
| const children = ((parent as any).children || []) as Content[]; | ||
| for (let i = 0; i < children.length; i++) { | ||
| const child = children[i]; | ||
| if (isElement(child)) { | ||
| const el = child; | ||
| if (el.tagName === 'jsx-email-cond') { | ||
| if (hasNestedConditional(el)) { | ||
| throw new RangeError( | ||
| 'jsx-email: Nested <Conditional> is not supported. Flatten your conditionals into a single block.' | ||
| ); | ||
| } | ||
| matches.push({ index: i, node: el, parent }); | ||
| } | ||
| // Recurse | ||
| walk(el); | ||
| } | ||
| } | ||
| }; |
There was a problem hiding this comment.
The nested-check performs a full recursive scan (hasNestedConditional) for every matched wrapper and also walks the tree separately, which is O(N^2) in the worst case. You can make a single DFS that tracks whether you’re already inside a <jsx-email-cond> and throw immediately if you encounter another—simpler and more efficient.
Suggestion
You can replace hasNestedConditional + walk with a single-pass visitor that carries an inCond flag and collects matches:
const matches: Array<{ index: number; node: Element; parent: Parents }> = [];
const visit = (parent: Parents, inCond: boolean): void => {
const children = ((parent as any).children || []) as Content[];
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (isElement(child)) {
const isCond = child.tagName === 'jsx-email-cond';
if (inCond && isCond) {
throw new RangeError(
'jsx-email: Nested <Conditional> is not supported. Flatten your conditionals into a single block.'
);
}
if (isCond) matches.push({ index: i, node: child as Element, parent });
visit(child as Parents, inCond || isCond);
}
}
};
visit(tree as unknown as Parents, false);Reply with "@CharlieHelps yes please" if you'd like me to add a commit applying this refactor.
Summary
Why
What changed
Behavior
Migration
Notes