Skip to content

fix(jsx-email): implement Conditional via rehype (no regex); preserve <Raw>; forbid nested <Conditional>#324

Closed
charliecreates[bot] wants to merge 25 commits into
mainfrom
ai-322-fix-raw-component-within-conditional-should
Closed

fix(jsx-email): implement Conditional via rehype (no regex); preserve <Raw>; forbid nested <Conditional>#324
charliecreates[bot] wants to merge 25 commits into
mainfrom
ai-322-fix-raw-component-within-conditional-should

Conversation

@charliecreates

Copy link
Copy Markdown
Contributor

Summary

  • Replace regex-based handling in Conditional renderer with a rehype-based transform.
  • Ensure content is preserved verbatim inside Conditional blocks (including line breaks/whitespace).
  • Disallow nested usage and surface a clear error message.

Why

What changed

  • packages/jsx-email/src/renderer/conditional.ts
    • Removed regex parsing/manipulation.
    • Implemented a rehype transform to construct the MSO conditional comment wrapper and pass through children unescaped.
    • Added a guard to detect nested and throw with a descriptive message.
  • Tests
    • Added minimal snapshot for inside .
    • Added coverage for correct <![endif]--> closer emission.
    • Added a negative test ensuring nested throws.

Behavior

  • For non-Raw children, behavior remains unchanged aside from more robust serialization via rehype.
  • For inside , content is now preserved exactly as authored (no escaping, line breaks maintained).
  • Nested now throws an error with guidance.

Migration

  • If you previously nested components, refactor to a single Conditional with combined logic or adjacent conditionals; nested usage is no longer supported and will throw at render time.

Notes

…; 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
…; 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
…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
…rop from packages/jsx-email/package.json per review\n- Update pnpm-lock.yaml to prune importer entry
… 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.
@charliecreates
charliecreates Bot requested a review from CharlieHelps October 18, 2025 01:10

@charliecreates charliecreates Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  • 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 inCond flag.
  • 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 raw payload preservation path for the mso case; the !mso downlevel-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 in renderer/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 jsxToString path for children serialization and retained comment/string helpers.
  • renderer/conditional.ts
    • Added a rehype plugin to unwrap <jsx-email-cond> wrappers and forbid nested conditionals.
    • Implemented parseLegacyConditional to convert the legacy single-comment placeholder shape into a raw node, preserving <Raw> payload verbatim.
    • Grouped splices by parent and applied in descending index order to avoid index invalidation.
  • 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 the Raw plugin; removed regex-based <jsx-email-cond> cleanup.
  • tests
    • Updated snapshot to assert that the final HTML preserves the <Raw> content inside the MSO conditional block.

Comment on lines +96 to +125
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);
}
}
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@charliecreates
charliecreates Bot removed the request for review from CharlieHelps October 18, 2025 01:16
@shellscape shellscape closed this Oct 18, 2025
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.

2 participants