🏖️ fix: Sandpack ExternalResources for Static HTML Artifact Previews (#12509)

* fix: omit externalResources for static Sandpack previews

The Tailwind CDN URL lacks a file extension, causing Sandpack's
static template to throw a runtime injection error. Static previews
already load Tailwind via a script tag in the shared index.html,
so externalResources is unnecessary for them.

Closes #12507

* refactor: extract buildSandpackOptions and add tests

- Surgically omit only externalResources for static templates
  instead of discarding all sharedOptions, preventing future
  regression if new template-agnostic options are added.
- Extract options logic into a pure, testable helper function.
- Add unit tests covering all template/config combinations.

* chore: fix import order and pin test assertions

* fix: use URL fragment hint instead of omitting externalResources

Sandpack's static template regex detects resource type from the URL's
last file extension. The versioned CDN path (/3.4.17) matched ".17"
instead of ".js", throwing "Unable to determine file type". Rather
than omitting externalResources for static templates (which would
remove the only Tailwind injection path for HTML artifacts that don't
embed their own script tag), append a #tailwind.js fragment hint so
the regex matches ".js". Fragments are not sent to the server, so
the CDN response is unchanged.
This commit is contained in:
Danny Avila 2026-04-01 22:06:42 -04:00 committed by GitHub
parent cb41ba14b2
commit 611a1ef5dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 64 additions and 11 deletions

View file

@ -6,7 +6,7 @@ import type {
} from '@codesandbox/sandpack-react/unstyled';
import type { TStartupConfig } from 'librechat-data-provider';
import type { ArtifactFiles } from '~/common';
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
import { sharedFiles, buildSandpackOptions } from '~/utils/artifacts';
export const ArtifactPreview = memo(function ({
files,
@ -39,15 +39,10 @@ export const ArtifactPreview = memo(function ({
};
}, [currentCode, files, fileKey]);
const options: typeof sharedOptions = useMemo(() => {
if (!startupConfig) {
return sharedOptions;
}
return {
...sharedOptions,
bundlerURL: template === 'static' ? startupConfig.staticBundlerURL : startupConfig.bundlerURL,
};
}, [startupConfig, template]);
const options: SandpackProviderProps['options'] = useMemo(
() => buildSandpackOptions(template, startupConfig),
[startupConfig, template],
);
if (Object.keys(artifactFiles).length === 0) {
return null;

View file

@ -0,0 +1,38 @@
import { buildSandpackOptions } from '../artifacts';
const TAILWIND_CDN = 'https://cdn.tailwindcss.com/3.4.17#tailwind.js';
describe('buildSandpackOptions', () => {
it('includes externalResources with .js fragment hint for static template', () => {
const options = buildSandpackOptions('static');
expect(options?.externalResources).toEqual([TAILWIND_CDN]);
});
it('includes externalResources for react-ts template', () => {
const options = buildSandpackOptions('react-ts');
expect(options?.externalResources).toEqual([TAILWIND_CDN]);
});
it('uses staticBundlerURL when template is static and config is provided', () => {
const config = { staticBundlerURL: 'https://static.example.com' } as Parameters<
typeof buildSandpackOptions
>[1];
const options = buildSandpackOptions('static', config);
expect(options?.bundlerURL).toBe('https://static.example.com');
expect(options?.externalResources).toEqual([TAILWIND_CDN]);
});
it('uses bundlerURL when template is react-ts and config is provided', () => {
const config = { bundlerURL: 'https://bundler.example.com' } as Parameters<
typeof buildSandpackOptions
>[1];
const options = buildSandpackOptions('react-ts', config);
expect(options?.bundlerURL).toBe('https://bundler.example.com');
expect(options?.externalResources).toEqual([TAILWIND_CDN]);
});
it('returns base options without bundlerURL when no config is provided', () => {
const options = buildSandpackOptions('react-ts');
expect(options?.bundlerURL).toBeUndefined();
});
});

View file

@ -4,6 +4,7 @@ import type {
SandpackProviderProps,
SandpackPredefinedTemplate,
} from '@codesandbox/sandpack-react';
import type { TStartupConfig } from 'librechat-data-provider';
const artifactFilename = {
'application/vnd.react': 'App.tsx',
@ -138,10 +139,29 @@ export function getProps(type: string): Partial<SandpackProviderProps> {
};
}
/** Fragment hint lets Sandpack's static-template regex detect `.js` from the URL;
* without it, the versioned CDN path (`/3.4.17`) has no recognised extension and
* `injectExternalResources` throws "Unable to determine file type". */
const TAILWIND_CDN = 'https://cdn.tailwindcss.com/3.4.17#tailwind.js';
export const sharedOptions: SandpackProviderProps['options'] = {
externalResources: ['https://cdn.tailwindcss.com/3.4.17'],
externalResources: [TAILWIND_CDN],
};
export function buildSandpackOptions(
template: SandpackProviderProps['template'],
startupConfig?: TStartupConfig,
): SandpackProviderProps['options'] {
if (!startupConfig) {
return sharedOptions;
}
return {
...sharedOptions,
bundlerURL: template === 'static' ? startupConfig.staticBundlerURL : startupConfig.bundlerURL,
};
}
export const sharedFiles = {
'/lib/utils.ts': shadcnComponents.utils,
'/components/ui/accordion.tsx': shadcnComponents.accordian,