Proof of concept: Nested <HtmlInCanvas>#7329
Conversation
There was a problem hiding this comment.
Important
The PR body already lists the three runtime problems (flickering, visible portal, no docs). On top of those, the design choices most worth pinning down before this evolves further are: (1) nested is added to the publicly exported HtmlInCanvasProps without an _experimental prefix, freezing a PoC contract into semver; (2) it's an opt-in flag for a condition the component can already detect via HtmlInCanvasAncestorContext; (3) the existing docs page still tells users nesting is unsupported and quotes the old error message. Inline comments cover the smaller correctness items.
TL;DR — Adds opt-in support for nesting <HtmlInCanvas> by portaling the inner instance into a hidden container on document.body and copying its painted bitmap into a shadow <canvas> placed where the inner used to live. Outer levels then capture the shadow canvas as part of their own paint, which lets effects compose across levels.
Key changes
- Add
nestedprop on<HtmlInCanvas>— opt-in flag that switches the inner instance to a portal-plus-shadow-canvas rendering path instead of throwing. - Portal to
document.body+ auxiliary<div>container — created viauseMemokeyed onnestedand appended in a layout effect; container style is justposition: absolute. - Shadow
<canvas>blit at the end ofonPaintCb— afterrunEffectChainfinishes, the layout canvas isdrawImaged into the shadow canvas so the parent capture sees post-effect content. - Loosen the nesting-throw guard — error only fires when
isInsideAncestorHtmlInCanvas && !nested; new error text references the new prop. HtmlInCanvasNestedEffectsexample — three levels (tint → rotation → blur) wired intoRoot.tsxashtml-in-canvas-nested-effects.
Summary | 4 files | 2 commits | base: main ← nested-html-in-canvas
API surface and naming
Before: Nesting throws unconditionally; no public flag.
After:nested?: booleanlands on the publicly exportedHtmlInCanvasProps(re-exported frompackages/core/src/index.ts:149), with no_experimentalprefix and no JSDoc warning.
This file already uses the _experimentalEffects / _experimentalControls convention for unstable surfaces. Given the PR body lists flickering, visible portals, and missing docs as known problems, the prop should follow the same convention (or be kept off HtmlInCanvasProps entirely until the design settles) so semver doesn't lock in a name and behavior that may still change. Separately, since the component already reads HtmlInCanvasAncestorContext, consider auto-detecting the nested case rather than requiring the user to pass a flag whose absence throws — there's no observable behavior the user gets by not opting in once they're nested.
Auxiliary container lifecycle
Before: N/A.
After: A<div>is created insideuseMemo(() => document.createElement('div'), [nested])and appended todocument.bodyin a layout effect.
useMemo is a performance hint — React reserves the right to discard the cache and recompute it (and StrictMode double-invokes the factory in development), so creating DOM nodes there can produce orphaned <div>s. The standard pattern is useState(() => document.createElement('div')), or move creation into the same layout effect that performs the append. The container's style is also just position: absolute with no inset/visibility/opacity, which matches the "portal nodes are visible in the Studio" issue you already flagged.
Stale documentation
Before:
packages/docs/docs/remotion/html-in-canvas.mdx:80-83instructs users that nesting is unsupported and quotes the old error verbatim.
After: Same docs, but the error message has changed and nesting is now opt-in.
Even before full nesting docs land, the existing "Do not nest" paragraph and the verbatim quote of the old error need to be updated, otherwise users searching for the new error message in docs will find the wrong guidance. packages/skills/skills/remotion/rules/html-in-canvas.md:13 has the same outdated quote.
Paint ordering between layers
Before: N/A — nesting threw.
After: Outer paint captures the shadow canvas, which is updated only at the end of the inner'sonPaintCbafterrunEffectChainresolves.
There is no synchronization that guarantees the inner has completed its current-frame paint before the outer captures it — on the first paint or any frame where the inner's async chain is still pending, the outer will composite the previous frame's shadow contents. This is a plausible mechanical source of the "flickering during render" item in the PR body. Worth deciding whether the inner should chain a delayRender that the outer awaits, or whether the contract is documented as "one-frame lag, accept the smear."
Claude Opus | 𝕏
| readonly children: React.ReactNode; | ||
| readonly onPaint?: HtmlInCanvasOnPaint; | ||
| readonly onInit?: HtmlInCanvasOnInit; | ||
| readonly nested?: boolean; |
There was a problem hiding this comment.
nested lands on the publicly exported HtmlInCanvasProps with no _experimental prefix, while this file already uses _experimentalEffects / _experimentalControls for unstable surfaces. Given the PR body lists flickering, visible portal nodes, and no docs as known problems, this should be _experimentalNested (or kept off the public type) so the name and semantics aren't frozen in semver before the design is settled.
| const auxiliaryCanvasContainer = useMemo(() => { | ||
| if (!nested || typeof document === 'undefined') { | ||
| return null; | ||
| } | ||
|
|
||
| const container = document.createElement('div'); | ||
| Object.assign(container.style, hiddenAuxiliaryCanvasContainerStyle); | ||
| return container; | ||
| }, [nested]); |
There was a problem hiding this comment.
useMemo is a performance hint — React explicitly reserves the right to discard cached values, and StrictMode double-invokes the factory in dev, so each invocation creates an extra <div> that nothing tracks. Use useState(() => ...) for lazy-initialized DOM nodes, or move createElement into the same useLayoutEffect that performs the append.
| const auxiliaryCanvasContainer = useMemo(() => { | |
| if (!nested || typeof document === 'undefined') { | |
| return null; | |
| } | |
| const container = document.createElement('div'); | |
| Object.assign(container.style, hiddenAuxiliaryCanvasContainerStyle); | |
| return container; | |
| }, [nested]); | |
| const [auxiliaryCanvasContainer] = useState<HTMLDivElement | null>(() => { | |
| if (!nested || typeof document === 'undefined') { | |
| return null; | |
| } | |
| const container = document.createElement('div'); | |
| Object.assign(container.style, hiddenAuxiliaryCanvasContainerStyle); | |
| return container; | |
| }); |
| }, [width, height]); | ||
|
|
||
| if (isInsideAncestorHtmlInCanvas) { | ||
| if (isInsideAncestorHtmlInCanvas && !nested) { |
There was a problem hiding this comment.
Since the component already reads HtmlInCanvasAncestorContext, the nested-rendering path can be selected automatically when isInsideAncestorHtmlInCanvas is true rather than requiring a separate prop whose absence throws. That removes the entire "forgot to pass nested" failure mode and shrinks the API surface to one decision the user actually has to make (either keep nested as an internal escape hatch only, or drop it altogether).
Resolve HtmlInCanvas import conflict: keep flattenEffects from main and continue using delayRender from useDelayRender (nested canvas PR). Co-authored-by: Cursor <cursoragent@cursor.com>
Restore flattenEffects and nested EffectsProp alongside main’s effect overrides; resolve HtmlInCanvas imports; flatten effects for Solid and HtmlInCanvas hooks. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Merged latest Tracked Chromium follow-up separately: #7400 (nested |
Use flat EffectsProp and useMemoizedEffects wiring everywhere again. Co-authored-by: Cursor <cursoragent@cursor.com>

Problems: