Skip to content

Proof of concept: Nested <HtmlInCanvas>#7329

Draft
JonnyBurger wants to merge 8 commits into
mainfrom
nested-html-in-canvas
Draft

Proof of concept: Nested <HtmlInCanvas>#7329
JonnyBurger wants to merge 8 commits into
mainfrom
nested-html-in-canvas

Conversation

@JonnyBurger
Copy link
Copy Markdown
Member

@JonnyBurger JonnyBurger commented May 12, 2026

Problems:

  • Flickering during render
  • Portal nodes are visible in the Studio
  • No documentation

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
bugs Ready Ready Preview, Comment May 15, 2026 1:53pm
remotion Ready Ready Preview, Comment May 15, 2026 1:53pm

Request Review

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

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

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 nested prop 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 via useMemo keyed on nested and appended in a layout effect; container style is just position: absolute.
  • Shadow <canvas> blit at the end of onPaintCb — after runEffectChain finishes, the layout canvas is drawImaged 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.
  • HtmlInCanvasNestedEffects example — three levels (tint → rotation → blur) wired into Root.tsx as html-in-canvas-nested-effects.

Summary | 4 files | 2 commits | base: mainnested-html-in-canvas


API surface and naming

Before: Nesting throws unconditionally; no public flag.
After: nested?: boolean lands on the publicly exported HtmlInCanvasProps (re-exported from packages/core/src/index.ts:149), with no _experimental prefix 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.

HtmlInCanvas.tsx


Auxiliary container lifecycle

Before: N/A.
After: A <div> is created inside useMemo(() => document.createElement('div'), [nested]) and appended to document.body in 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.

HtmlInCanvas.tsx


Stale documentation

Before: packages/docs/docs/remotion/html-in-canvas.mdx:80-83 instructs 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.

html-in-canvas.mdx


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's onPaintCb after runEffectChain resolves.

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."

HtmlInCanvas.tsx

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread packages/core/src/HtmlInCanvas.tsx Outdated
readonly children: React.ReactNode;
readonly onPaint?: HtmlInCanvasOnPaint;
readonly onInit?: HtmlInCanvasOnInit;
readonly nested?: boolean;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread packages/core/src/HtmlInCanvas.tsx Outdated
Comment on lines +333 to +341
const auxiliaryCanvasContainer = useMemo(() => {
if (!nested || typeof document === 'undefined') {
return null;
}

const container = document.createElement('div');
Object.assign(container.style, hiddenAuxiliaryCanvasContainerStyle);
return container;
}, [nested]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Suggested change
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;
});

Comment thread packages/core/src/HtmlInCanvas.tsx
Comment thread packages/core/src/HtmlInCanvas.tsx Outdated
}, [width, height]);

if (isInsideAncestorHtmlInCanvas) {
if (isInsideAncestorHtmlInCanvas && !nested) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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).

Comment thread packages/core/src/HtmlInCanvas.tsx
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>
@JonnyBurger
Copy link
Copy Markdown
Member Author

Merged latest main and resolved conflicts in HtmlInCanvas / effects.

Tracked Chromium follow-up separately: #7400 (nested HtmlInCanvas verified on Chrome 150.0.7841.0 canary arm64; not on 149 stable — next step is bumping bundled/CI Chrome again).

Use flat EffectsProp and useMemoizedEffects wiring everywhere again.

Co-authored-by: Cursor <cursoragent@cursor.com>
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.

1 participant