Remotion integration probe — resolved
@remotion/player now renders inside this Fresh app under Preact compat. The left pane below is live evidence — type a name, watch the composition update on the next loop. The technical sections beneath the video record the original failure (Fresh issue #3802) and the workaround that fixed it.
Resolution
Fixed by an out-of-band esbuild rebundle of @remotion/player + remotion, with react → preact/compat applied via esbuild's alias at the bare-specifier resolution stage — before any node_modules lookup. The output is a self-contained ESM at static/vendor/remotion-bundle.mjs (~410 KB raw, ~110 KB gz) with preact/compat fused in. Islands consume it via runtime URL import (so Vite never re-processes the bundle and can't re-introduce real React) and mount Player using the bundle's own render + h (host preact won't recognise components made by bundle preact — different $$typeof markers — so the host does the outer container, the bundle does the inside).
Build script: scripts/build-vendor-remotion.ts. Re-run via deno task vendor:remotion after Remotion / preact upgrades.
Why this matters
Pre-rendered mp4 already works for a static homepage hero — we ship it. The reason we'd want @remotion/player in the browser is runtime input props: <Player> accepts an inputProps object that gets passed straight into the composition's React component, so the same Remotion video can render different content per customer / per scenario without a server-side render pass per variant. Concrete uses we have in mind for skyprospector:
- Per-proposal promo on
/proposals/{id}that animates this customer's site config and projected savings. - Live "what-if" previews on the configurator — change a slider, the Sankey morph + savings figure update without waiting for an mp4 render job.
- A/B framing of the homepage promo without re-rendering the file each time.
Without Player working, every variant requires @remotion/renderer on the server (slower, cacheable but not interactive). With Player, the rendering happens in the browser at 60 fps on the user's machine, fed by reactive props.
Original failure (TL;DR)
@remotion/player ships a pre-bundled ESM that evaluates var Player = forwardRef(PlayerFn) at module load. Its internal import "react" resolved to real react@19 from node_modules instead of the preact/compat alias declared in deno.json. Real React's forwardRef returns { $$typeof, render } (a plain object); Preact's returns a function. Preact silently failed to render the object, JSX <Player> produced empty DOM, no error fired. Fresh 2.3.3's release notes said "React compat aliasing works properly now and packages like Radix UI work out of the box" — that's true for packages that mark React as an external peer dep, but not for packages whose pre-bundled ESM has React baked in via internal imports. Per Fresh maintainer bartlomieju on the issue, Deno's npm resolver hard-resolves bare specifiers to absolute paths under node_modules/.deno/ before Vite's plugin chain runs, so resolve.alias, optimizeDeps.include, optimizeDeps.exclude, and optimizeDeps.esbuildOptions.alias all fail to intercept it.
Live evidence
Both panes mount in the same page under the Fresh nav. Left: @remotion/player loaded from the vendor bundle, with a name input driving runtime inputProps. Right: pre-rendered /homepage-promo.mp4 served from static/ (the trusted baseline that always worked).
@remotion/player (vendor-bundled)
Loaded from /vendor/remotion-bundle.mjs, mounted via the bundle's preact render.
Static mp4 (control)
Pre-rendered with remotion render, served from static/.
Setup
- Fresh
2.3.3(upgraded from 2.2.0 mid-investigation) - Preact
10.29.1 @remotion/player+remotionat4.0.456- Deno's npm: resolution + auto node_modules
deno.json import map (relevant excerpt):
"react": "npm:preact@^10.27.2/compat", "react-dom": "npm:preact@^10.27.2/compat", "react/jsx-runtime": "npm:preact@^10.27.2/jsx-runtime", "remotion": "npm:remotion@^4.0.455", "@remotion/player": "npm:@remotion/player@^4.0.455"
The standalone vite.config.ts we ended up shipping is now minimal — just a defensive resolve.alias for any other npm package whose ESM does its own import "react":
export default defineConfig({
resolve: {
alias: {
"react/jsx-runtime": "preact/jsx-runtime",
"react": "preact/compat",
"react-dom": "preact/compat",
},
dedupe: ["preact", "preact/compat", "react", "react-dom"],
},
});The rebundle script (scripts/build-vendor-remotion.ts) uses npm:esbuild via Deno, with react → preact/compat applied via esbuild's alias (which intercepts at bare-specifier resolution, before any node_modules lookup):
await esbuild.build({
stdin: { contents: `
export { Player, Thumbnail } from "@remotion/player";
export { AbsoluteFill, interpolate, spring, useCurrentFrame, ... } from "remotion";
export { render, h, createElement, Fragment, hydrate } from "preact";
`, resolveDir: projectRoot, sourcefile: "remotion-vendor-entry.ts", loader: "ts" },
bundle: true,
format: "esm",
outfile: "static/vendor/remotion-bundle.mjs",
alias: {
"react": "preact/compat",
"react-dom": "preact/compat",
"react/jsx-runtime": "preact/jsx-runtime",
"react-dom/client": "preact/compat",
},
platform: "browser",
target: "es2022",
define: { "process.env.NODE_ENV": '"production"' },
});What you see now
- @remotion/player (vendor-bundled): composition autoplays + loops; the name input drives the caption via
inputPropsand the playback head doesn't reset on keystrokes. Status line at the top showstypeof Player = function(preact/compat's forwardRef shape, no longer the real-React object). ✅ - Static mp4: autoplays, controls work, file is ~2.7 MB / 20 s @ 1920×1080 / 15 fps. Always worked. ✅
Diagnostic shape: before vs after
The asymmetry that pinned down the root cause. Before the vendor bundle, the island's own import "react" resolved to preact/compat (function), but Player's bundled-in import "react" resolved to real react@19 (object):
// BEFORE — Player baked against real react@19
{
typeofPlayer: "object", // wrong: should be "function"
keys: ["$$typeof", "render"], // real-React forwardRef shape
}
// AFTER — Player baked against preact/compat (vendor bundle)
{
typeofPlayer: "function", // ✓ preact's forwardRef shape
keys: ["$$typeof", "render", "__f", ...],
}The status line on the live pane above shows the post-fix shape (typeof Player = function) directly.
Root cause
From node_modules/.deno/@remotion+player@4.0.456/.../dist/esm/index.mjs:
// line ~3493
var forward = forwardRef2; // imported as: import { forwardRef as forwardRef2 } from "react";
var Player = forward(PlayerFn); // evaluated at module-load time
// line ~3756
export { Thumbnail, PlayerInternals, Player };When Fresh's bundler serves Player to the browser, the import "react" inside this pre-built ESM does not get rewritten by the deno.json alias or by vite.config.ts's resolve.alias. It resolves through node_modules to react@19. That react's forwardRef is called once at module load, returning { $$typeof: REACT_FORWARD_REF_TYPE, render: PlayerFn }, and that object is then exported as Player. By the time Preact tries to render <Player>, the resolution decision has already happened — there's nothing to alias.
Preact's renderer doesn't recognise the React-flavoured $$typeof marker (Preact uses a different sentinel), so it silently treats the value as not-a-component and produces no DOM. No throw, no warning.
Why Radix UI works but @remotion/player doesn't
The Fresh 2.3 announcement specifically calls out Radix UI as working out of the box. The asymmetry isn't a Radix-vs-Player quality difference; it's a packaging difference:
- Radix UI publishes per-primitive packages whose pre-bundled artifacts mark React and ReactDOM as external peer deps. The published JS contains
import from "react"references that are still unresolved at publish time — they hit the consumer's Vite/bundler at install time. Vite'sresolve.aliassees them and rewrites them topreact/compat. Works. - @remotion/player ships a single rolled-up ESM file (over 3 700 lines, see output above) where
forwardRefhas already been called against the React it bundled against. The shape of the exportedPlayerconstant is fixed at the package's build time, not at the consumer's bundle time. Vite's alias intercepts theimport "react"statement at the top of the file (so the variable nameforwardRef2now points at preact/compat's forwardRef), butvar Player = forward(PlayerFn)runs at module-evaluation time — and at that point the object shape is whatever the resolved react gave us. With the deno.json alias not applied transitively to the npm package's bundled internal calls, it gets real react.
Things tried in-band — none fixed it
Recording these for the Fresh issue tracker — they're each a plausible-looking fix but don't survive Deno's pre-resolution of "react" to node_modules/.deno/react@19/...:
- Client-only mount via
useEffect. Stoppedpreact-render-to-stringfrom throwing"[object Object] is not a valid HTML tag name"during SSR, but didn't change the client-side empty render. - Fresh 2.2.0 → 2.3.3 upgrade via
deno task update. Release notes promised React-compat aliasing applied before Deno resolution ("Radix UI works out of the box"). No change to the Player case. - Standalone vite.config.ts with
resolve.alias+dedupe+optimizeDeps.include: ["@remotion/player", "remotion"]. No change. Per Fresh maintainer bartlomieju: Deno's npm resolver has already converted bare"react"to an absolute path before Vite's plugin chain runs. optimizeDeps.esbuildOptions.aliasapplied during pre-bundling (suggested by lunadogbot in the issue). Doesn't intercept either, for the same reason — by the time esbuild sees the import, it's already a path, not a bare specifier.optimizeDeps.exclude: ["@remotion/player", "remotion"](also lunadogbot). Disables pre-bundling so the project-level alias gets a clean shot at the unresolved imports — except they aren't unresolved, they're already pre-resolved by Deno. The served island chunk still embedsnode_modules/.deno/react@19.x.x/...verbatim.
What worked: out-of-band esbuild rebundle
The fix runs esbuild as a standalone tool (npm:esbuild via Deno), invoked outside Deno's npm resolution graph. esbuild's alias config is the first resolver — bare specifier "react" → "preact/compat" gets rewritten before any node_modules lookup. The bundle ships preact/compat fused in (no real React anywhere) and is served as a static asset at /vendor/remotion-bundle.mjs (committed to the repo so Deno Deploy serves it without any build step).
Two non-obvious wiring details on the consuming side:
- Dynamic import via runtime URL. Vite's static analyser must NOT see the bundle URL or it'll try to re-resolve through Deno's npm graph and re-introduce real React. Pull the URL from a const so it's a variable expression inside
import(), plus a/* @vite-ignore */pragma as belt-and-braces. - Mount Player using the bundle's
render+h, not host JSX. The bundle has its own preact instance fused in, with its own$$typeofmarkers. Host preact won't recognise components made by bundle preact — host JSX<Player ...>silently no-ops (we hit this on the way to the working state). Solution: re-exportrender+hfrom the bundle's preact, then in the island, render Player into a side container (a div the host preact owns the OUTER of, but with no JSX children, so host preact's diff doesn't manage what bundle preact puts inside).
Upstream asks (the vendor bundle is a workaround, not a fix)
Shipping the workaround unblocks our use case, but the underlying issue affects every npm package whose pre-bundled ESM imports React internally (Remotion, framer-motion, @react-three/fiber, …). What'd actually fix the class:
A. For Fresh / @fresh/plugin-vite (most useful)
- Apply the deno.json import map alias when serving npm package artifacts, not just user code. Today the alias is honoured for top-level imports from the project's source files — but a pre-bundled npm package's internal
import "react"slips through. If the Fresh plugin can re-rewrite imports insidenode_modules/.deno/.../*.mjson the way out (or via Vite'soptimizeDepstransform pipeline with the alias active), this whole class of bug disappears: every React npm package starts working under preact/compat, not just the ones that mark React as external. - Document the limitation explicitly in the meantime. The Fresh 2.3 blog claim "Packages like Radix UI work out of the box" is true but case-specific. A short doc page distinguishing "packages that mark React as external" (work) from "packages that bundle React internally" (don't work yet) — with a recommended workaround — would have saved us several hours and is the kind of thing newcomers will keep hitting (Remotion, framer-motion, @react-three/fiber, anything with a heavy react-internals usage that does its own bundle).
- Make
vite.config.tsat project root opt-in for advanced cases. If the standalone config is ignored today, that's a footgun — at minimum log a warning when one is detected. If it's already read, document which fields override Fresh defaults.
B. For Vite (upstream)
- vite#3910 ("Pre-bundled dependencies doesn't dedupe imports in external files") has been open since 2021 and is the upstream variant of this. A real fix here would let Fresh pass it through.
- Make
resolve.aliasapply transitively into optimizeDeps-bundled artifacts (or surface a clear "I am not aliasing this" warning).
C. For users today — what we did
The vendor-bundle path described above. ~1–2 h of script work, ~410 KB raw / ~110 KB gz output. Documented in this repo at:
scripts/build-vendor-remotion.ts— the esbuild rebundle entryislands/TestRemotionPlayer.tsx— the consuming pattern (dynamic URL import + bundle-render mount)static/vendor/remotion-bundle.mjs— the committed bundle artifactCLAUDE.md"Homepage promo video & Remotion" section — full narrative for future maintainers
Other workarounds that would also work but we didn't ship:
- iframe isolation — render a tiny standalone real-React app that hosts Player; embed it in the Fresh page via
<iframe>. More robust if a package trips over a different preact/compat gap downstream, but heavyweight per embed. - server-side render via
@remotion/renderer(or another offline renderer), serve the resulting mp4. Right answer for static autoplay heroes regardless of bundler issues (it's what we ship for the homepage promo). Doesn't unlock interactive runtimeinputProps.
D. Out of scope
- Switching skypro-fresh to React would solve it but isn't warranted just for one package.
- Patching
@remotion/playerupstream to expose React as an external peer dep would help this specific case, but the underlying class of bug would still exist for any other package with a similar packaging choice.
Files of interest in skypro-fresh: routes/test-remotion.tsx (this page), islands/TestRemotionPlayer.tsx, scripts/build-vendor-remotion.ts, static/vendor/remotion-bundle.mjs.