Skip to content

SVG: vectorEffect breaks hydration, and streamed Suspense inside <svg> lowercases attributes #121

Open

Description

@azizbecha

Version: @tanstack/redact 0.2.3 and main (37c051c). Baseline: react / react-dom 19.3.1. Browser: headless Chrome 154.

preserveAspectRatio and vectorEffect were both lowercased up to 0.2.0. #76 fixed the common preserveAspectRatio case in 0.2.1, but two gaps remain, and React handles both correctly.

Path preserveAspectRatio vectorEffect
renderToString Correct Emits vectorEffect (React emits vector-effect)
After Chrome parses that HTML Correct Becomes vectoreffect, ignored
Hydration Clean, server nodes kept Mismatch, server <main> replaced
createRoot Correct, including updates vectorEffect, ignored
Streamed Suspense directly inside <svg> preserveaspectratio, ignored vectoreffect, ignored

Without an onRecoverableError handler, the hydration failure logs nothing.

1. vectorEffect is never mapped to vector-effect, and this breaks hydration

attributeName() in packages/redact/src/core/attributes.ts only hyphenates SVG props starting with clip, fill or stroke, so vectorEffect passes through unchanged. On the client the browser ignores it. On the server, the HTML parser lowercases it to vectoreffect because it isn't a known SVG attribute name. Hydration then looks up vectorEffect, doesn't find it, and discards the boundary:

Hydration mismatch on <path> for "vectorEffect".
const tree = h(Suspense, { fallback: null },
  h('main', null,
    h('svg', null, h('path', { d: 'M0 0L9 9', vectorEffect: 'non-scaling-stroke' }))))

The computed vector-effect is none under Redact and non-scaling-stroke under React. This differs from what #81 describes for presentation attributes ("hydration reports nothing"): in a real browser, the parser's lowercasing makes hydration fail for this prop.

Workaround: style={{ vectorEffect: 'non-scaling-stroke' }} renders correctly and hydrates cleanly.

2. Streamed Suspense directly inside <svg> loses the SVG context

// Late suspends until after the shell has flushed
h('main', null,
  h('svg', { viewBox: '0 0 9 9' },
    h(Suspense, { fallback: null }, h(Late))))
// Late renders <symbol preserveAspectRatio="none" />

Redact emits:

<svg viewBox="0 0 9 9"><!--$?0--><div id="B:0"></div><!--/$--></svg>
...
<div hidden id="S:0"><symbol preserveaspectratio="none"></symbol></div>
  • The boundary's content is walked with no SVG context, so attributeName() takes its lowercase fallback.
  • The <div> placeholder makes the HTML parser break out of the <svg>, and the segment is parsed in an HTML <div>. After reveal, the <symbol> is a child of <main>, in the HTML namespace.

Under React the same tree reveals inside the <svg>, in the SVG namespace, with preserveAspectRatio intact. A boundary outside the <svg> is unaffected under Redact, which is also the workaround.

Related

  • #42: the original lowercasing report. Its preserveAspectRatio case is fixed outside streaming.
  • #81 / #96: the wider set of SVG presentation attributes. From reading the diff, fix: map camelCase SVG presentation props to hyphenated attributes #96 would also map vectorEffect. I haven't run it.
  • #103: streamed insertion context for tables, SVG and MathML. From reading the diff, it targets the second gap. I haven't run it, and it currently has merge conflicts.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions