diff --git a/src/components/CodeExplorer.tsx b/src/components/CodeExplorer.tsx
index bf6373be6..0ae42bc38 100644
--- a/src/components/CodeExplorer.tsx
+++ b/src/components/CodeExplorer.tsx
@@ -1,23 +1,24 @@
import React from 'react'
-import { FileExplorer } from './FileExplorer'
+import { FileExplorer, type FileExplorerNode } from './FileExplorer'
import { InteractiveSandbox } from './InteractiveSandbox'
import { CodeExplorerTopBar } from './CodeExplorerTopBar'
-import type { GitHubFileNode } from '~/utils/documents.server'
+import { getExamplePanels, type ExamplePanel } from '~/utils/example-panel'
import type { Library } from '~/libraries'
import { twMerge } from 'tailwind-merge'
import { CodeBlock } from '~/components/markdown'
import { getCodeBlockLanguageFromFilePath } from '~/components/markdown/codeBlock.shared'
interface CodeExplorerProps {
- activeTab: 'code' | 'sandbox'
+ activeTab: ExamplePanel
codeSandboxUrl: string
currentCode: string
currentPath: string
examplePath: string
- githubContents: GitHubFileNode[] | undefined
+ githubContents: FileExplorerNode[] | undefined
library: Library
prefetchFileContent: (path: string) => void
- setActiveTab: (tab: 'code' | 'sandbox') => void
+ playground?: React.ReactNode
+ setActiveTab: (tab: ExamplePanel) => void
setCurrentPath: (path: string) => void
stackBlitzUrl: string
}
@@ -31,12 +32,23 @@ export function CodeExplorer({
githubContents,
library,
prefetchFileContent,
+ playground,
setActiveTab,
setCurrentPath,
stackBlitzUrl,
}: CodeExplorerProps) {
const [isFullScreen, setIsFullScreen] = React.useState(false)
const [isSidebarOpen, setIsSidebarOpen] = React.useState(true)
+ const [hasOpenedPlayground, setHasOpenedPlayground] = React.useState(
+ activeTab === 'playground',
+ )
+ React.useEffect(() => {
+ if (activeTab === 'playground') setHasOpenedPlayground(true)
+ }, [activeTab])
+ const panels = getExamplePanels({
+ ...library,
+ hasPlayground: Boolean(playground),
+ })
const currentCodeLanguage = getCodeBlockLanguageFromFilePath(currentPath)
// Add escape key handler
@@ -60,6 +72,7 @@ export function CodeExplorer({
>
-
-
setIsSidebarOpen(false)}
- prefetchFileContent={prefetchFileContent}
- setCurrentPath={setCurrentPath}
- />
+ {playground && (hasOpenedPlayground || activeTab === 'playground') ? (
+
+ {playground}
+
+ ) : null}
+ {!playground ? (
-
setIsSidebarOpen(false)}
+ prefetchFileContent={prefetchFileContent}
+ setCurrentPath={setCurrentPath}
+ />
+
-
- {currentCode}
-
-
+
+
+ {currentCode}
+
+
+
-
-
+ ) : null}
+ {!library.hideStackblitzUrl ? (
+
+ ) : null}
+ {!library.hideCodesandboxUrl ? (
+
+ ) : null}
)
diff --git a/src/components/CodeExplorerTopBar.tsx b/src/components/CodeExplorerTopBar.tsx
index a902d5687..e5c841baf 100644
--- a/src/components/CodeExplorerTopBar.tsx
+++ b/src/components/CodeExplorerTopBar.tsx
@@ -7,18 +7,21 @@ import {
TextAlignLeftIcon,
} from '@phosphor-icons/react'
import { Tooltip } from '~/ui'
+import type { ExamplePanel, ExamplePanelOption } from '~/utils/example-panel'
interface CodeExplorerTopBarProps {
- activeTab: 'code' | 'sandbox'
+ activeTab: ExamplePanel
+ panels: ReadonlyArray
isFullScreen: boolean
isSidebarOpen: boolean
- setActiveTab: (tab: 'code' | 'sandbox') => void
+ setActiveTab: (tab: ExamplePanel) => void
setIsFullScreen: React.Dispatch>
setIsSidebarOpen: (isOpen: boolean) => void
}
export function CodeExplorerTopBar({
activeTab,
+ panels,
isFullScreen,
isSidebarOpen,
setActiveTab,
@@ -27,7 +30,7 @@ export function CodeExplorerTopBar({
}: CodeExplorerTopBarProps) {
return (
-
+
{activeTab === 'code' ? (
isSidebarOpen ? (
@@ -62,38 +65,21 @@ export function CodeExplorerTopBar({
/>
)}
-
-
+ {panels.map((panel) => (
+
+ ))}
-
-
-
-
-
+ {}}
+ setActiveTab={setActiveTab}
+ setCurrentPath={setCurrentPath}
+ stackBlitzUrl={stackBlitzUrl}
+ playground={
+
+
+
+
+
+ }
+ />
)
@@ -674,7 +718,7 @@ function ClientExampleFallback({
const language = getCodeBlockLanguageFromFilePath(path)
return (
-
+
diff --git a/src/utils/example-file-tree.ts b/src/utils/example-file-tree.ts
new file mode 100644
index 000000000..daef4a8bf
--- /dev/null
+++ b/src/utils/example-file-tree.ts
@@ -0,0 +1,31 @@
+import type { FileExplorerNode } from '../components/FileExplorer'
+
+export function createExampleFileTree(paths: Array) {
+ const root: Array = []
+
+ for (const path of paths) {
+ const segments = path.split('/').filter(Boolean)
+ let children = root
+
+ for (const [index, name] of segments.entries()) {
+ const nodePath = `/${segments.slice(0, index + 1).join('/')}`
+ const last = index === segments.length - 1
+ let node = children.find((candidate) => candidate.path === nodePath)
+
+ if (!node) {
+ node = {
+ children: last ? undefined : [],
+ depth: index,
+ name,
+ path: nodePath,
+ type: last ? 'file' : 'dir',
+ }
+ children.push(node)
+ }
+
+ if (node.children) children = node.children
+ }
+ }
+
+ return root
+}
diff --git a/src/utils/example-imports.ts b/src/utils/example-imports.ts
index b0af4edce..12e783ee5 100644
--- a/src/utils/example-imports.ts
+++ b/src/utils/example-imports.ts
@@ -354,7 +354,10 @@ async function resolvePackageGroup(
}),
])
const metadata = [requestedMetadata, ...additionalMetadata]
- const peerPackages = new Set(packageManifest.peerPackages)
+ // Declaration packages have no browser module or esm.sh runtime metadata.
+ const peerPackages = new Set(
+ packageManifest.peerPackages.filter((name) => !name.startsWith('@types/')),
+ )
const peerSpecifiers = new Set()
for (const value of metadata) {
@@ -369,6 +372,7 @@ async function resolvePackageGroup(
if (!peer) {
throw new Error(`Invalid peer import in esm.sh metadata: ${peerImport}`)
}
+ if (peer.packageName.startsWith('@types/')) continue
peerPackages.add(peer.packageName)
peerSpecifiers.add(peer.specifier)
}
@@ -521,7 +525,16 @@ function createEsmModuleUrl(
query: string,
externalPackages: ReadonlyArray,
) {
- const url = `${esmOrigin}/${packageName}@${version}${subpath ? `/${subpath}` : ''}${query}`
+ let url = `${esmOrigin}/${packageName}@${version}${subpath ? `/${subpath}` : ''}${query}`
+ // Production devtools exports can be no-ops, and esm.sh's default splitting
+ // duplicates contexts across lazy chunks. Keep development devtools together
+ // while sharing their framework peers with the example.
+ if (
+ packageName.startsWith('@tanstack/') &&
+ (packageName === '@tanstack/devtools' || packageName.endsWith('-devtools'))
+ ) {
+ url = appendQuery(url, '?dev&standalone')
+ }
return externalPackages.length
? appendQuery(url, `?external=${externalPackages.join(',')}`)
: url
diff --git a/src/utils/example-panel.ts b/src/utils/example-panel.ts
new file mode 100644
index 000000000..565879a7e
--- /dev/null
+++ b/src/utils/example-panel.ts
@@ -0,0 +1,31 @@
+export type ExamplePanel = 'playground' | 'code' | 'stackblitz' | 'codesandbox'
+
+export type ExamplePanelOption = { id: ExamplePanel; label: string }
+
+export function getExamplePanels({
+ hasPlayground = false,
+ hideStackblitzUrl = false,
+ hideCodesandboxUrl = false,
+}: {
+ hasPlayground?: boolean
+ hideStackblitzUrl?: boolean
+ hideCodesandboxUrl?: boolean
+}): Array {
+ const panels: Array = []
+ if (hasPlayground) panels.push({ id: 'playground', label: 'Playground' })
+ else panels.push({ id: 'code', label: 'Code Explorer' })
+ if (!hideStackblitzUrl) panels.push({ id: 'stackblitz', label: 'StackBlitz' })
+ if (!hideCodesandboxUrl)
+ panels.push({ id: 'codesandbox', label: 'CodeSandbox' })
+ return panels
+}
+
+export function getExamplePanel(
+ value: string | undefined,
+ panels: ReadonlyArray,
+ embedEditor: 'stackblitz' | 'codesandbox' = 'stackblitz',
+) {
+ // Preserve links and preferences saved before the providers had separate tabs.
+ const panel = value === 'sandbox' ? embedEditor : value
+ return panels.find((candidate) => candidate.id === panel)?.id
+}
diff --git a/src/utils/example-sandbox.client.ts b/src/utils/example-sandbox.client.ts
index 00abec2fe..1170b378c 100644
--- a/src/utils/example-sandbox.client.ts
+++ b/src/utils/example-sandbox.client.ts
@@ -887,6 +887,26 @@ function createRuntimeBridgeScript(
) {
return `const runToken = ${JSON.stringify(runToken)}
+// Opaque sandbox origins cannot use browser storage. Keep example settings
+// inside this preview instead of granting access to the host site's origin.
+for (const name of ['localStorage', 'sessionStorage']) {
+ try {
+ if (window[name]) continue
+ } catch {}
+ const entries = new Map()
+ Object.defineProperty(window, name, {
+ configurable: true,
+ value: {
+ get length() { return entries.size },
+ clear() { entries.clear() },
+ getItem(key) { return entries.get(String(key)) ?? null },
+ key(index) { return [...entries.keys()][index] ?? null },
+ removeItem(key) { entries.delete(String(key)) },
+ setItem(key, value) { entries.set(String(key), String(value)) },
+ },
+ })
+}
+
function send(value) {
parent.postMessage({ type: 'tanstack-example-sandbox', runToken, ...value }, '*')
}
diff --git a/src/utils/sandbox.ts b/src/utils/sandbox.ts
index 9f2167dc1..946bdea53 100644
--- a/src/utils/sandbox.ts
+++ b/src/utils/sandbox.ts
@@ -1,5 +1,30 @@
import { type Framework } from '~/libraries'
+export function getExampleSandboxUrls({
+ repo,
+ branch,
+ examplePath,
+ file,
+ isDark,
+}: {
+ repo: string
+ branch: string
+ examplePath: string
+ file: string
+ isDark: boolean
+}) {
+ const path = `${repo}/tree/${branch}/examples/${examplePath}`
+ const search = new URLSearchParams({
+ embed: '1',
+ theme: isDark ? 'dark' : 'light',
+ file,
+ })
+ return {
+ stackBlitzUrl: `https://stackblitz.com/github/${path}?${search}&preset=node`,
+ codeSandboxUrl: `https://codesandbox.io/p/devbox/github/${path}?${search}`,
+ }
+}
+
export const getExampleStartingPath = (
framework: Framework,
libraryId?: string,
diff --git a/src/utils/stackblitz-embed.ts b/src/utils/stackblitz-embed.ts
index 41c0ac867..af971ae2e 100644
--- a/src/utils/stackblitz-embed.ts
+++ b/src/utils/stackblitz-embed.ts
@@ -1,5 +1,3 @@
-import type { IframeHTMLAttributes } from 'react'
-
export const stackBlitzEmbedHeaders = {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'credentialless',
@@ -15,9 +13,32 @@ export function getExampleRuntimeHeaders(
return stackBlitzEmbedHeaders
}
+export function shouldReloadExampleDocument(
+ browser: {
+ isSecureContext: boolean
+ crossOriginIsolated: boolean
+ sessionStorage: Pick
+ },
+ pathname: string,
+) {
+ if (!browser.isSecureContext) return false
+ const key = `tanstack-example-document-reload:${pathname}`
+ try {
+ if (browser.crossOriginIsolated) {
+ browser.sessionStorage.removeItem(key)
+ return false
+ }
+ if (browser.sessionStorage.getItem(key) === '1') return false
+ browser.sessionStorage.setItem(key, '1')
+ return true
+ } catch {
+ // Without per-tab storage, reloading could create a loop.
+ return false
+ }
+}
+
export const stackBlitzIframeProps = {
allow: 'cross-origin-isolated',
- credentialless: '',
-} as IframeHTMLAttributes & {
- credentialless: string
+ // React 19.2 needs a string for this attribute; Redact needs it to be truthy.
+ credentialless: 'true',
}
diff --git a/tests/example-imports.test.ts b/tests/example-imports.test.ts
index a02671cf8..d183d24a3 100644
--- a/tests/example-imports.test.ts
+++ b/tests/example-imports.test.ts
@@ -335,6 +335,72 @@ test('resolves declared peers that source code does not import', async () => {
})
})
+test('does not resolve type-only devtools peers as browser modules', async () => {
+ const workspace = createExampleWorkspace({
+ entry: '/index.tsx',
+ files: {
+ '/index.tsx': 'export {}',
+ '/package.json': JSON.stringify({
+ dependencies: {
+ '@tanstack/react-devtools': '0.10.12',
+ react: '19.3.0',
+ 'react-dom': '19.3.0',
+ },
+ devDependencies: {
+ '@types/react': '^19.3.0',
+ '@types/react-dom': '^19.3.0',
+ },
+ }),
+ },
+ })
+ const requests: Array = []
+ const fetchMetadata = createMetadataFetch(
+ requests,
+ (url) => ({
+ version: url.includes('@tanstack/react-devtools') ? '0.10.12' : '19.3.0',
+ }),
+ (url): Record =>
+ url.includes('@tanstack/react-devtools')
+ ? {
+ '@types/react': '>=16.8',
+ '@types/react-dom': '>=16.8',
+ react: '>=16.8',
+ 'react-dom': '>=16.8',
+ }
+ : {},
+ )
+ const imports = await resolveExampleWorkspaceImports(
+ workspace,
+ workspace.files,
+ new Set(['@tanstack/react-devtools']),
+ {
+ fetch: async (input, init) => {
+ // esm.sh redirects @types metadata requests to declarations.
+ if (getRequestUrl(input).includes('/@types/')) {
+ requests.push(getRequestUrl(input))
+ return new Response('export as namespace ReactDOM;')
+ }
+ return fetchMetadata(input, init)
+ },
+ },
+ )
+
+ assert.equal(
+ imports['@tanstack/react-devtools'],
+ 'https://esm.sh/@tanstack/react-devtools@0.10.12?dev&standalone&external=react,react-dom',
+ )
+ assert.equal(imports.react, 'https://esm.sh/react@19.3.0')
+ assert.equal(imports['react-dom'], 'https://esm.sh/react-dom@19.3.0')
+ assert.equal(
+ requests.some((url) => url.includes('/@types/')),
+ false,
+ )
+ assert.equal(
+ Object.keys(imports).some((key) => key.startsWith('@types/')),
+ false,
+ )
+})
+
test('stops before resolving packages when already aborted', async () => {
const workspace = createExampleWorkspace({
entry: '/index.ts',
diff --git a/tests/example-panel.test.ts b/tests/example-panel.test.ts
new file mode 100644
index 000000000..1b81fba7b
--- /dev/null
+++ b/tests/example-panel.test.ts
@@ -0,0 +1,53 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { getExamplePanel, getExamplePanels } from '../src/utils/example-panel'
+import { getExampleSandboxUrls } from '../src/utils/sandbox'
+
+test('offers both external editors alongside the supported playground', () => {
+ assert.deepEqual(getExamplePanels({ hasPlayground: true }), [
+ { id: 'playground', label: 'Playground' },
+ { id: 'stackblitz', label: 'StackBlitz' },
+ { id: 'codesandbox', label: 'CodeSandbox' },
+ ])
+ assert.deepEqual(
+ getExamplePanels({}).map((panel) => panel.id),
+ ['code', 'stackblitz', 'codesandbox'],
+ )
+})
+
+test('preserves legacy sandbox links without selecting unsupported panels', () => {
+ const panels = getExamplePanels({})
+ assert.equal(getExamplePanel('sandbox', panels), 'stackblitz')
+ assert.equal(getExamplePanel('sandbox', panels, 'codesandbox'), 'codesandbox')
+ assert.equal(getExamplePanel('codesandbox', panels), 'codesandbox')
+ assert.equal(getExamplePanel('playground', panels), undefined)
+ assert.equal(getExamplePanel('unknown', panels), undefined)
+ const hidden = getExamplePanels({
+ hideStackblitzUrl: true,
+ hideCodesandboxUrl: true,
+ })
+ assert.deepEqual(hidden, [{ id: 'code', label: 'Code Explorer' }])
+ assert.equal(getExamplePanel('sandbox', hidden), undefined)
+})
+
+test('external editors use the same example and encode the selected file', () => {
+ const urls = getExampleSandboxUrls({
+ repo: 'tanstack/table',
+ branch: 'v8',
+ examplePath: 'svelte/header-groups',
+ file: 'src/a file.svelte',
+ isDark: true,
+ })
+ for (const value of Object.values(urls)) {
+ const url = new URL(value)
+ assert.ok(
+ url.pathname.endsWith(
+ '/tanstack/table/tree/v8/examples/svelte/header-groups',
+ ),
+ )
+ assert.equal(url.searchParams.get('file'), 'src/a file.svelte')
+ assert.equal(url.searchParams.get('theme'), 'dark')
+ assert.equal(url.searchParams.get('embed'), '1')
+ }
+ assert.equal(new URL(urls.stackBlitzUrl).searchParams.get('preset'), 'node')
+})
diff --git a/tests/example-sandbox.test.ts b/tests/example-sandbox.test.ts
index 02b87294b..cdf993de3 100644
--- a/tests/example-sandbox.test.ts
+++ b/tests/example-sandbox.test.ts
@@ -29,6 +29,87 @@ test('keeps fragment links inside the sandbox document', () => {
assert.match(document, /const channel = "test-browser"/)
})
+test('devtools can read and write storage inside an opaque sandbox', () => {
+ const document = createExampleSandboxDocument({
+ compiled: { css: '', imports: {}, javascript: '' },
+ document: undefined,
+ entry: '/index.ts',
+ files: { '/index.ts': '' },
+ runToken: 'storage-test',
+ theme: 'light',
+ })
+ const bridge = document.match(/