Repository navigation
Tonic UI Public ESM Deployment
日期:2026-10-08
使用公開 esm.sh;不部署自架 server、不需要私有 registry credential。Tonic、React、Emotion 各自 CDN-first/local fallback,不再整份 app 一起切換三組 runtime。
文件入口:分組 fallback 架構 · 建置設定與獨立 demo
本地 Tonic UI branch:feat/cdn-first-runtime-demo。獨立 demo 已在工作區實作並以真實 browser 驗證;該 branch 尚未提交、推送或部署到官方 docs。Wiki 更新不等於發布 demo。
在已有依賴與已建置 Tonic workspaces 的 repository 中,從 packages/react-docs 執行:
yarn build:cdn-runtime此命令不呼叫 build:mfe。正常 docs build/dev 也會組裝 demo。開啟 Getting Started → CDN-first runtime,或在 docs base path 下直接開啟 /demos/cdn-runtime/index.html。生成產物只需 static hosting;iframe 只隔離 docs 的 React runtime,demo 不使用 Module Federation、Wujie 或 MFE server。
若尚未取得此本地 branch,下面提供完整 demo sources 與兩種 bundler 設定,可在既有 Tonic UI workspace 的 packages/react-docs/demos/cdn-runtime/ 重建示範。不需要替換產品現有 plugins/loaders。
| Build | 打包的內容 | 保留 external |
|---|---|---|
react |
React/React DOM/JSX runtime 的多入口共享 chunks | 無 shared runtime external |
emotion |
Emotion 多入口共享 chunks | React family |
tonic-cdn |
App、另一個 ESM widget | React、Emotion、全部所用 Tonic imports |
tonic-local |
App、widget、Tonic 與其他非共享 dependencies | React、Emotion |
三組來源可以各自選 CDN 或 local。兩份 Tonic app variant 加兩份 vendor builds 支援八種組合,無需針對每種組合重新編譯 app。Local Tonic 不可以帶入另一份 React/Emotion。
同一組 vendor entries 必須一次 multi-entry build。Vite 以共享 ESM chunks 維持 identity;Webpack 還需要 runtimeChunk: 'single',避免各 entry 使用獨立 module cache。App 與 widget 同樣共享 Tonic provider/context。
以下只列 browser runtime packages,包含目前使用的 internal subpaths。新入口必須加入 inventory;local build 出現 unmapped external,或 local Tonic 仍保留任何 @tonic-ui/* import,assembly 會失敗。
entries.mjs
export const localEntries = {
react: {
react: { specifier: 'react', file: 'entries/react.js' },
'react-dom': { specifier: 'react-dom', file: 'entries/react-dom.js' },
'react-dom-client': { specifier: 'react-dom/client', file: 'entries/react-dom-client.js' },
'jsx-runtime': { specifier: 'react/jsx-runtime', file: 'entries/jsx-runtime.js' },
'jsx-dev-runtime': { specifier: 'react/jsx-dev-runtime', file: 'entries/jsx-dev-runtime.js' },
},
emotion: {
'emotion-react': { specifier: '@emotion/react', file: 'entries/emotion-react.js' },
'emotion-jsx-runtime': { specifier: '@emotion/react/jsx-runtime', file: 'entries/emotion-jsx-runtime.js' },
'emotion-jsx-dev-runtime': { specifier: '@emotion/react/jsx-dev-runtime', file: 'entries/emotion-jsx-dev-runtime.js' },
'emotion-styled': { specifier: '@emotion/styled', file: 'entries/emotion-styled.js' },
'emotion-styled-base': { specifier: '@emotion/styled/base', file: 'entries/emotion-styled-base.js' },
'emotion-cache': { specifier: '@emotion/cache', file: 'entries/emotion-cache.js' },
'emotion-is-prop-valid': { specifier: '@emotion/is-prop-valid', file: 'entries/emotion-is-prop-valid.js' },
},
};
export const tonicPackages = [
'@tonic-ui/react', '@tonic-ui/react-base', '@tonic-ui/react-hooks',
'@tonic-ui/react-icons', '@tonic-ui/styled-system', '@tonic-ui/theme', '@tonic-ui/utils',
];
export const tonicSpecifiers = [
...tonicPackages, '@tonic-ui/react-base/internal', '@tonic-ui/utils/internal',
];
export const isReact = id => /^(react|react-dom)(\/|$)/.test(id);
export const isEmotion = id => id.startsWith('@emotion/');
export const isTonic = id => id.startsWith('@tonic-ui/');建立薄 wrapper entries(新目錄使用一次)
在 demo 目錄保存以下內容為 create-entries.mjs,執行 node create-entries.mjs。Repository branch 已包含這些 entries,不需要再次建立。
import { mkdir, writeFile } from 'node:fs/promises';
const entries = {
"react.js": "export * from 'react';\nexport { default } from 'react';\n",
"react-dom.js": "export * from 'react-dom';\nexport { default } from 'react-dom';\n",
"react-dom-client.js": "export * from 'react-dom/client';\n",
"jsx-runtime.js": "export * from 'react/jsx-runtime';\n",
"jsx-dev-runtime.js": "export * from 'react/jsx-dev-runtime';\n",
"emotion-react.js": "export * from '@emotion/react';\n",
"emotion-jsx-runtime.js": "export * from '@emotion/react/jsx-runtime';\n",
"emotion-jsx-dev-runtime.js": "export * from '@emotion/react/jsx-dev-runtime';\n",
"emotion-styled.js": "export * from '@emotion/styled';\nexport { default } from '@emotion/styled';\n",
"emotion-styled-base.js": "export * from '@emotion/styled/base';\nexport { default } from '@emotion/styled/base';\n",
"emotion-cache.js": "export * from '@emotion/cache';\nexport { default } from '@emotion/cache';\n",
"emotion-is-prop-valid.js": "export * from '@emotion/is-prop-valid';\nexport { default } from '@emotion/is-prop-valid';\n"
};
await mkdir('entries', { recursive: true });
for (const [name, source] of Object.entries(entries)) {
await writeFile(`entries/${name}`, source);
}兩個普通 ESM entries 都使用同一張 import map。App export mount(element, widget),頂層不 mount;widget 的 hooks 與 theme 在 app 的同一個 TonicProvider 下執行。
src/app.js
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
import { Box, Button, TonicProvider, useColorMode } from '@tonic-ui/react';
function App({ Widget }) {
const [count, setCount] = useState(0);
const [colorMode, setColorMode] = useColorMode();
const toggleColorMode = () => setColorMode(value => value === 'dark' ? 'light' : 'dark');
return React.createElement(Box, { p: '4x' },
React.createElement('h2', null, 'Standalone CDN-first app'),
React.createElement('p', { id: 'app-theme' }, `App theme: ${colorMode}`),
React.createElement(Button, { variant: 'primary', onClick: () => setCount(value => value + 1), id: 'app-counter' }, `App clicks: ${count}`),
React.createElement(Button, { ml: '2x', variant: 'secondary', onClick: toggleColorMode, id: 'theme-toggle' }, 'Toggle shared theme'),
React.createElement(Widget));
}
export function mount(element, widget) {
createRoot(element).render(React.createElement(TonicProvider, null,
React.createElement(App, { Widget: widget.Widget })));
}src/widget.js
import React, { useState } from 'react';
import styled from '@emotion/styled';
import { Box, Button, useColorMode } from '@tonic-ui/react';
import { CodeIcon } from '@tonic-ui/react-icons';
const Frame = styled.section({ border: '2px solid rgb(0, 128, 0)', padding: 16, marginTop: 16 });
export function Widget() {
const [count, setCount] = useState(0);
const [colorMode] = useColorMode();
return React.createElement(Frame, { id: 'widget-frame' },
React.createElement(Box, { display: 'flex', alignItems: 'center', mb: '2x' },
React.createElement(CodeIcon, { mr: '2x' }),
React.createElement('h3', null, 'Shared-runtime ESM widget')),
React.createElement('p', { id: 'widget-theme' }, `Widget theme: ${colorMode}`),
React.createElement(Button, { variant: 'primary', onClick: () => setCount(value => value + 1), id: 'widget-counter' }, `Widget clicks: ${count}`));
}已以現有 Vite 6.4.3 執行。以下保留 mount/Widget exports,不以 HTML entry 自動啟動 app。React 的 CJS forwarding index 不能只依靠 export *;plugin 明確轉出所選版本的 named exports。
vite.config.mjs
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
import { localEntries, isReact, isEmotion, isTonic } from './entries.mjs';
const root = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
export default defineConfig(({ mode }) => {
const vendor = localEntries[mode];
if (!vendor && mode !== 'tonic-cdn' && mode !== 'tonic-local') {
throw new Error(`Unknown runtime build: ${mode}`);
}
const input = vendor
? Object.fromEntries(Object.entries(vendor).map(([name, entry]) => [name, resolve(root, entry.file)]))
: { app: resolve(root, 'src/app.js'), widget: resolve(root, 'src/widget.js') };
const external = mode === 'react'
? undefined
: mode === 'emotion'
? isReact
: id => isReact(id) || isEmotion(id) || (mode === 'tonic-cdn' && isTonic(id));
return {
root,
base: './',
publicDir: false,
plugins: mode === 'react'
? [{
name: 'react-commonjs-named-exports',
transform(code, id) {
const entry = Object.values(localEntries.react).find(item => resolve(root, item.file) === id);
if (!entry) {
return null;
}
// React's CJS index forwards module.exports. export * alone loses its names.
const names = Object.keys(require(entry.specifier)).filter(name => name !== 'default' && name !== '__esModule');
return `${code}\nexport { ${names.join(', ')} } from '${entry.specifier}';\n`;
},
}]
: [],
build: {
outDir: resolve(root, `../../public/demos/cdn-runtime/${mode}`),
emptyOutDir: true,
manifest: true,
target: 'es2022',
modulePreload: false,
rollupOptions: { input, external, preserveEntrySignatures: 'strict' },
},
};
});Assembly 從實際 emitted chunks 取得 entry/CSS,從已安裝 package metadata 取得 exact root versions。cdnUrl 不使用 latest。CDN 轉換時不把 Tonic 自己的 scope 全部 externalize:esm.sh 可能輸出未公開的 private self-import;app bundler 的 Tonic external 策略和 esm.sh 的 dependency query 不是同一件事。
react-focus-lock/dist/cjs 明確映射到同一個已安裝版本的 ESM root。這保留 focus-lock 功能,不是吞掉轉換例外。仍需產品的 modal/focus restoration 驗收。
build.mjs
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { copyFile, mkdir, writeFile } from 'node:fs/promises';
import { build } from 'vite';
import { localEntries, tonicPackages, tonicSpecifiers, isTonic } from './entries.mjs';
const root = dirname(fileURLToPath(import.meta.url));
const outputRoot = resolve(root, '../../public/demos/cdn-runtime');
const require = createRequire(import.meta.url);
const packageName = specifier => specifier.startsWith('@')
? specifier.split('/').slice(0, 2).join('/')
: specifier.split('/')[0];
const packages = [...new Set([
...Object.values(localEntries).flatMap(entries => Object.values(entries).map(entry => packageName(entry.specifier))),
...tonicPackages, 'react-focus-lock',
])];
const versions = Object.fromEntries(packages.map(name => [name, require(`${name}/package.json`).version]));
if (versions.react !== versions['react-dom']) {
throw new Error('React and React DOM versions must match');
}
function cdnUrl(specifier, external = []) {
const name = packageName(specifier);
const subpath = specifier.slice(name.length);
const query = new URLSearchParams({ target: 'es2022' });
if (external.length) {
query.set('external', external.join(','));
}
return `https://esm.sh/${name}@${versions[name]}${subpath}?${query}`;
}
const groups = {};
const allowedImports = new Set([
...Object.values(localEntries).flatMap(entries => Object.values(entries).map(entry => entry.specifier)),
...tonicSpecifiers,
]);
const apps = {};
for (const mode of ['react', 'emotion', 'tonic-cdn', 'tonic-local']) {
const result = await build({ configFile: resolve(root, 'vite.config.mjs'), mode });
const chunks = result.output.filter(item => item.type === 'chunk');
const files = new Set(chunks.map(chunk => chunk.fileName));
for (const chunk of chunks) {
for (const id of [...chunk.imports, ...chunk.dynamicImports]) {
if (mode === 'tonic-local' && isTonic(id)) {
throw new Error(`Local Tonic build still imports ${id}`);
}
if (!files.has(id) && !allowedImports.has(id)) {
throw new Error(`Unmapped runtime import: ${id}`);
}
}
}
const entryChunks = new Map(chunks.filter(chunk => chunk.isEntry).map(chunk => [chunk.name, chunk]));
const descriptor = name => {
const entry = entryChunks.get(name);
if (!entry) {
throw new Error(`Missing ${mode} entry: ${name}`);
}
const css = new Set();
const seen = new Set();
function collect(chunk) {
if (seen.has(chunk.fileName)) {
return;
}
seen.add(chunk.fileName);
for (const file of chunk.viteMetadata.importedCss) {
css.add(`./${mode}/${file}`);
}
for (const file of chunk.imports) {
const dependency = chunks.find(item => item.fileName === file);
if (dependency) {
collect(dependency);
}
}
}
collect(entry);
return { entry: `./${mode}/${entry.fileName}`, css: [...css] };
};
if (localEntries[mode]) {
const imports = Object.fromEntries(Object.entries(localEntries[mode]).map(([name, entry]) => [entry.specifier, descriptor(name).entry]));
groups[mode] = { local: { imports, probes: Object.keys(imports) }, cdn: null };
} else {
apps[mode === 'tonic-cdn' ? 'cdn' : 'local'] = { app: descriptor('app'), widget: descriptor('widget') };
}
}
const reactExternal = ['react', 'react-dom'];
const emotionExternal = [...reactExternal, '@emotion/react', '@emotion/styled', '@emotion/cache', '@emotion/is-prop-valid'];
for (const group of ['react', 'emotion']) {
const probes = groups[group].local.probes;
const imports = Object.fromEntries(probes.map(specifier => {
const external = group === 'react'
? specifier === 'react' ? [] : reactExternal
: packageName(specifier) === '@emotion/styled'
? [...reactExternal, '@emotion/react']
: reactExternal;
return [specifier, cdnUrl(specifier, external)];
}));
groups[group].cdn = { imports, probes };
}
// Do not externalize a package's own scope: esm.sh may expose private self-imports.
const tonicExternal = [...emotionExternal, 'react-focus-lock'];
const tonicImports = Object.fromEntries(tonicSpecifiers.map(specifier => [specifier, cdnUrl(specifier, tonicExternal)]));
tonicImports['react-focus-lock/dist/cjs'] = cdnUrl('react-focus-lock', reactExternal);
groups.tonic = {
local: { imports: {}, probes: [] },
cdn: { imports: tonicImports, probes: tonicSpecifiers },
};
await mkdir(outputRoot, { recursive: true });
await writeFile(resolve(outputRoot, 'runtime-manifest.json'), `${JSON.stringify({ versions, groups, apps }, null, 2)}\n`);
for (const name of ['index.html', 'bootstrap.js']) {
await copyFile(resolve(root, name), resolve(outputRoot, name));
}
console.log('Built isolated CDN-first demo with paired local vendors and two Tonic app variants.');從 demo 目錄可執行 node build.mjs;repository 專用命令指向同一個 script。
保存以下內容為 demo 目錄的 webpack.config.mjs,重用上面的 entries、app、widget。產品原有 JSX/TSX、CSS loaders 與 plugins 應保留。externalsType: 'module' 使用 native ESM import,不依賴 window.React。
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { localEntries, isReact, isEmotion, isTonic } from './entries.mjs';
const root = dirname(fileURLToPath(import.meta.url));
export default (env = {}) => {
const mode = env.runtime || 'tonic-local';
const vendor = localEntries[mode];
if (!vendor && mode !== 'tonic-cdn' && mode !== 'tonic-local') {
throw new Error(`Unknown runtime build: ${mode}`);
}
const entry = vendor
? Object.fromEntries(Object.entries(vendor).map(([name, item]) => [name, resolve(root, item.file)]))
: { app: resolve(root, 'src/app.js'), widget: resolve(root, 'src/widget.js') };
const external = mode === 'react'
? () => false
: mode === 'emotion'
? isReact
: id => isReact(id) || isEmotion(id) || (mode === 'tonic-cdn' && isTonic(id));
return {
mode: 'production',
context: root,
target: ['web', 'es2022'],
entry,
experiments: { outputModule: true },
output: {
path: resolve(root, `../../public/demos/cdn-runtime-webpack/${mode}`),
filename: '[name].[contenthash].mjs',
chunkFilename: '[name].[contenthash].mjs',
publicPath: 'auto',
module: true,
library: { type: 'module' },
clean: true,
},
externalsType: 'module',
externals: [({ request }, callback) => {
if (request && external(request)) {
callback(null, request, 'module');
return;
}
callback();
}],
optimization: {
runtimeChunk: 'single',
splitChunks: { chunks: 'all', minSize: 0 },
},
};
};以產品既有的 Webpack 5/CLI 執行,不在此安裝或升級工具:
npx --no-install webpack --config webpack.config.mjs --env runtime=react --json=react-stats.json
npx --no-install webpack --config webpack.config.mjs --env runtime=emotion --json=emotion-stats.json
npx --no-install webpack --config webpack.config.mjs --env runtime=tonic-cdn --json=tonic-cdn-stats.json
npx --no-install webpack --config webpack.config.mjs --env runtime=tonic-local --json=tonic-local-stats.jsonWebpack 與 Vite assembly 使用相同 descriptor contract,但不要用 Vite 的 build script 讀 Webpack 輸出:
- 每個 stats 的
entrypoints[name].assets取出以該 entry name 開頭的 entry.mjs,並收集 CSS。Native entry imports 會載入其 runtime/shared chunks;部署必須保留全部輸出。 - 依相同 inventory 建立
groups.react.local.imports、groups.emotion.local.imports,並填入兩種appsentries/CSS。 - CDN URLs 與 startup probes 使用同一份 installed-version metadata。Webpack asset URLs 改為相對於同源 manifest 的
cdn-runtime-webpack/輸出。 - 將 bootstrap、HTML、descriptor 與四組完整 output 一起原子部署。不要只上傳 entry,或將兩次 app build 的 manifest 混用。
本次使用 Next 所附 Webpack 5.98.0 API 產生四組 ESM output 並在 browser 驗證,不是 Webpack CLI production 驗收。環境缺少該 bundled API 所要求的 Terser plugin,原始 minification 執行失敗;smoke 明確關閉 minification,未改上面產品 config 的預設值。正式 Webpack/minifier 流程仍須在產品既有工具鏈執行。
runtime-manifest.json 由 assembly 生成,不手寫 hashed filenames:
| 欄位 | 內容 |
|---|---|
versions |
已安裝 root package 的 exact versions |
groups.react/groups.emotion/groups.tonic
|
各組的 local 與 cdn descriptor |
各 descriptor 的 imports
|
Bare specifier → 相對同源 entry 或 exact CDN URL |
各 descriptor 的 probes
|
啟動時真正 dynamic import 的 entry inventory |
apps.cdn/apps.local
|
對應 Tonic source 的 app/widget entry 與啟動 CSS |
某組未配對或未核准的 CDN descriptor 設為 null。Local Tonic 的 imports/probes 為空,因為所有 Tonic code 已在 local app/widget chunks;React/Emotion 仍由其 group maps 決定。
Bootstrap 先安裝一張完整 import map,再依序 probe React、Emotion、Tonic。某組 CDN 失敗時只加入該組到 __local,重新載入後丟棄舊 realm。其他組維持 CDN-first;但它們仍可能因自己的 network/budget 失敗而獨立改用 local。不能要求真實公開網路測試中的其他組必定成功。
示範的 budget 是每組 8 秒,不是存在性檢查或 production SLA;冷建置/無快取下載可能超時。Native imports 不能取消或原地換綁定。Local、app assets、CSS、mount errors 顯示錯誤,不做自動 fallback。後續任意 lazy imports 不在此示範涵蓋範圍。
Classic bootstrap 必須先於任何 app module/CDN preload 執行。CSP nonce 可放在 bootstrap script,會傳給動態 import map;仍須配置產品的 script/style policy。以下 HTML 含故障選單、來源表、log 與 descriptor 連結:
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tonic UI CDN-first runtime demo</title>
<style>
body { margin: 0; padding: 16px; font: 14px/1.5 system-ui, sans-serif; color: #20242a; background: #fff; }
h1 { margin: 0 0 8px; font-size: 22px; }
label, select, button { font: inherit; }
select, button { margin: 4px 8px 4px 0; padding: 6px; }
table { width: 100%; border-collapse: collapse; margin: 12px 0; }
th, td { border: 1px solid #c8ced5; padding: 8px; text-align: left; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; background: #f5f6f8; padding: 12px; }
#startup-error { color: #b00020; }
</style>
</head>
<body>
<h1>CDN-first runtime groups</h1>
<p>This standalone demo shows esm.sh CDN modules and local build artifacts. The app and its ESM widget share one selected runtime per family. No Module Federation or Wujie is used.</p>
<label for="scenario">Failure scenario</label>
<select id="scenario">
<option value="cdn-first">CDN-first (real esm.sh modules)</option>
<option value="tonic-package">One Tonic package fails: react-icons</option>
<option value="tonic-internal">One Tonic internal subpath fails</option>
<option value="tonic-dependency">One Tonic transitive dependency fails: focus-lock</option>
<option value="react-package">React DOM client fails</option>
<option value="emotion-package">Emotion styled/base fails</option>
<option value="no-tonic-cdn">No paired Tonic CDN descriptor</option>
<option value="all-cdn-fail">All three CDN groups fail</option>
<option value="all-local">Use local for all groups</option>
</select>
<button id="retry" type="button">Retry from CDN-first</button>
<table aria-label="Selected runtime sources">
<thead><tr><th>Atomic family</th><th>Selected source</th></tr></thead>
<tbody>
<tr><th scope="row">React + React DOM + JSX runtimes</th><td id="react-source">Selecting...</td></tr>
<tr><th scope="row">Emotion + its mapped subpaths</th><td id="emotion-source">Selecting...</td></tr>
<tr><th scope="row">All @tonic-ui/*</th><td id="tonic-source">Selecting...</td></tr>
</tbody>
</table>
<div id="root"></div>
<p id="startup-error" role="alert" hidden>Cannot start the demo. Check the load log and browser console.</p>
<details open><summary>Load log</summary><pre id="runtime-log" aria-live="polite"></pre></details>
<details><summary>Exact package versions from this build</summary><pre id="versions"></pre></details>
<p><a href="./runtime-manifest.json" target="_blank" rel="noopener noreferrer">Inspect the generated runtime descriptor and esm.sh URLs</a></p>
<p>Failure presets map a chosen CDN import to an intentionally missing same-origin module. Use DevTools with cache disabled to test a real CDN outage. Group failures reload this iframe before mounting; they do not change the parent docs runtime.</p>
<script defer src="./bootstrap.js" data-manifest="./runtime-manifest.json"></script>
</body>
</html>bootstrap.js
(() => {
const script = document.currentScript;
const manifestUrl = new URL(script.dataset.manifest, location.href);
const nonce = script.nonce;
const groupNames = ['react', 'emotion', 'tonic'];
const url = new URL(location.href);
const forced = new Set((url.searchParams.get('__local') || '').split(',').filter(group => groupNames.includes(group)));
const scenario = url.searchParams.get('scenario') || 'cdn-first';
const log = document.getElementById('runtime-log');
const scenarioControl = document.getElementById('scenario');
scenarioControl.value = scenario;
scenarioControl.addEventListener('change', () => {
const next = new URL(location.href);
next.searchParams.set('scenario', scenarioControl.value);
next.searchParams.delete('__local');
next.searchParams.delete('__failed');
location.assign(next);
});
document.getElementById('retry').addEventListener('click', () => {
const next = new URL(location.href);
next.searchParams.delete('__local');
next.searchParams.delete('__failed');
location.assign(next);
});
function note(message) {
log.textContent += `${message}\n`;
}
const failed = (url.searchParams.get('__failed') || '').split(',').filter(group => groupNames.includes(group));
if (failed.length) {
note(`Previous document: CDN load failed for ${failed.join(', ')}. Reloaded before mounting.`);
}
function injectFailure(runtime) {
const missing = new URL('./deliberately-missing-module.js', manifestUrl).href;
const fail = (group, specifier) => {
runtime.groups[group].cdn.imports[specifier] = missing;
};
if (scenario === 'tonic-package') {
fail('tonic', '@tonic-ui/react-icons');
}
if (scenario === 'tonic-internal') {
fail('tonic', '@tonic-ui/react-base/internal');
}
if (scenario === 'tonic-dependency') {
fail('tonic', 'react-focus-lock/dist/cjs');
}
if (scenario === 'react-package') {
fail('react', 'react-dom/client');
}
if (scenario === 'emotion-package') {
fail('emotion', '@emotion/styled/base');
}
if (scenario === 'all-cdn-fail') {
fail('react', 'react-dom/client');
fail('emotion', '@emotion/styled/base');
fail('tonic', '@tonic-ui/react-icons');
}
if (scenario === 'no-tonic-cdn') {
runtime.groups.tonic.cdn = null;
}
if (scenario === 'all-local') {
groupNames.forEach(group => forced.add(group));
}
if (scenario !== 'cdn-first') {
note(`Demonstration scenario: ${scenario}. Missing-module scenarios inject a real same-origin module 404, not a simulated successful import.`);
}
}
function withBudget(promise) {
let timer;
const timeout = new Promise((resolve, reject) => {
timer = setTimeout(() => reject(new Error('CDN load budget exceeded (8 seconds)')), 8000);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
function loadCss(files) {
return Promise.all(files.map(file => new Promise((resolve, reject) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = new URL(file, manifestUrl).href;
link.onload = resolve;
link.onerror = () => reject(new Error(`Cannot load ${link.href}`));
document.head.append(link);
})));
}
async function run() {
const response = await fetch(manifestUrl);
if (!response.ok) {
throw new Error(`Cannot load runtime manifest: ${response.status}`);
}
const runtime = await response.json();
injectFailure(runtime);
const selected = Object.fromEntries(groupNames.map(group => [group,
!forced.has(group) && runtime.groups[group].cdn !== null ? 'cdn' : 'local',
]));
const imports = {};
for (const group of groupNames) {
const candidate = runtime.groups[group][selected[group]];
for (const [specifier, target] of Object.entries(candidate.imports)) {
if (Object.hasOwn(imports, specifier)) {
throw new Error(`Conflicting import map entry: ${specifier}`);
}
imports[specifier] = new URL(target, manifestUrl).href;
}
document.getElementById(`${group}-source`).textContent = selected[group] === 'cdn' ? 'CDN (esm.sh)' : 'Local (same origin)';
document.body.dataset[group] = selected[group];
}
document.getElementById('versions').textContent = JSON.stringify(runtime.versions, null, 2);
const map = document.createElement('script');
map.type = 'importmap';
if (nonce) {
map.nonce = nonce;
}
map.textContent = JSON.stringify({ imports });
document.head.append(map);
// Probe complete families in order. Emotion/Tonic imports use the chosen React,
// and Tonic imports use the chosen Emotion through this one immutable map.
for (const group of groupNames) {
const candidate = runtime.groups[group][selected[group]];
document.body.dataset.stage = group;
note(`Loading ${group}: ${selected[group]}...`);
try {
const loading = Promise.all(candidate.probes.map(specifier => import(specifier)));
await (selected[group] === 'cdn' ? withBudget(loading) : loading);
} catch (error) {
console.error(`Cannot load ${selected[group]} ${group} runtime`, error);
if (selected[group] === 'local') {
throw error;
}
forced.add(group);
const next = new URL(location.href);
next.searchParams.set('__local', groupNames.filter(name => forced.has(name)).join(','));
next.searchParams.set('__failed', [...new Set([...failed, group])].join(','));
// Native imports cannot be cancelled or safely rebound. Discard this realm.
location.replace(next);
return;
}
note(`Ready: ${group} (${selected[group]}).`);
}
const assets = runtime.apps[selected.tonic];
document.body.dataset.stage = 'app';
await loadCss([...new Set([...assets.app.css, ...assets.widget.css])]);
const [app, widget] = await Promise.all([
import(new URL(assets.app.entry, manifestUrl).href),
import(new URL(assets.widget.entry, manifestUrl).href),
]);
// App/CSS/mount failures are not evidence that a runtime family is unavailable.
await app.mount(document.getElementById('root'), widget);
document.body.dataset.stage = 'mounted';
note('App and widget loaded. Both use the same selected React, Emotion and Tonic runtime.');
}
run().catch(error => {
console.error(error);
document.body.dataset.stage = 'error';
note(`Startup error: ${error.message}`);
document.getElementById('startup-error').hidden = false;
});
})();故障 presets 將指定 import 改為同源不存在的 module,實際得到 404/import rejection;不是宣稱 esm.sh outage。測試真實 outage 時須停用 browser cache,再封鎖對應 CDN requests。
示範直接版本:Tonic UI 3.0.0-alpha.1、React/React DOM 19.2.7、Emotion React 11.14.0、Emotion Styled 11.14.1。以下不是後面 React 18 PoC 的同一張 map。
| 檢查 | 觀察結果 |
|---|---|
| Vite 四組 build 與 scoped ESLint | build 成功;修正新 config 的 indentation 後 lint 無錯誤/warnings |
| 八種 React/Emotion/Tonic CDN-local 組合 | 兩個 counters 可互動;共享 theme 一起改變;Emotion 綠色 border 正常;Network 無同組混載 |
| Tonic package、internal subpath、focus-lock 依賴故障 presets | Tonic 整組 local,React/Emotion 維持 CDN |
| React 或 Emotion 故障 preset | 只切換該組,其他組維持 CDN |
| 無 Tonic CDN descriptor/三組故障/全 local | 選擇正確;最終全 local document 不請求 esm.sh modules |
| Tonic module evaluation 不完成 | 8 秒 budget 後重載一次,只切換 Tonic;新 document 不請求 CDN Tonic |
| Local Emotion module 缺失/app mount 主動拋錯 | 顯示 startup error,不再重載或自動切換其他組 |
| Webpack 全 local 與兩種相反的混合來源 | 真實 Webpack ESM output 可 mount;兩個 counters、共享 theme、Emotion border 正常;全 local 無 CDN requests |
| Docs 展示頁內選擇 Tonic package 故障 | 只有 iframe 重載;父 docs URL 不變;React/Emotion CDN、Tonic local;兩個 counters 與共享 theme 正常 |
額外無快取公開 CDN 封鎖測試在 focus-lock case 觀察到 React 也獨立載入失敗,最終為 React local/Emotion CDN/Tonic local。該 run 的固定期望值 assertion 失敗,沒有宣稱整個真實 outage matrix 通過,也沒有擴大 Tonic fallback 範圍或提高 budget 來掩蓋結果。
固定 root versions 不等於固定全部遞迴 dependencies。本例不宣稱完成 SSR、全部 components/modals、後續 lazy routes、production module graph audit 或完整產品 release gates。
以下舊 PoC 保留作為 import 格式示範;沒有 local fallback。一般產品使用上面的分組 vendor builds、兩種 Tonic app 與 bootstrap,不將 React 18 PoC map 配給 React 19 local build。
本範例使用:Tonic UI 3.0.0-alpha.1、React/React DOM 18.3.1、Emotion 11.14.0。這是固定的 PoC 組合,不代表 alpha 版本已通過產品上線驗收。
將以下內容保存為獨立 PoC 目錄的 index.html。不需要 npm install、不使用 JSX,也不會把應用原始碼傳到遠端轉譯服務。
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tonic UI ESM CDN PoC</title>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.3.1?target=es2022",
"react/jsx-runtime": "https://esm.sh/react@18.3.1/jsx-runtime?target=es2022",
"react-dom": "https://esm.sh/react-dom@18.3.1?external=react&target=es2022",
"react-dom/client": "https://esm.sh/react-dom@18.3.1/client?external=react&target=es2022",
"@emotion/react": "https://esm.sh/@emotion/react@11.14.0?external=react&target=es2022",
"@emotion/react/jsx-runtime": "https://esm.sh/@emotion/react@11.14.0/jsx-runtime?external=react&target=es2022",
"@emotion/styled": "https://esm.sh/@emotion/styled@11.14.0?external=react,@emotion/react&target=es2022",
"@emotion/styled/base": "https://esm.sh/@emotion/styled@11.14.0/base?external=react,@emotion/react&target=es2022",
"react-focus-lock/dist/cjs": "https://esm.sh/react-focus-lock@2.13.7?external=react&target=es2022",
"@tonic-ui/react": "https://esm.sh/@tonic-ui/react@3.0.0-alpha.1?external=react,react-dom,@emotion/react,@emotion/styled,react-focus-lock&target=es2022"
}
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module">
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
import { Button, TonicProvider } from '@tonic-ui/react';
function Demo() {
const [count, setCount] = useState(0);
return React.createElement(
TonicProvider,
null,
React.createElement(
Button,
{ onClick: () => setCount(value => value + 1) },
`Clicked ${count} times`
)
);
}
createRoot(document.getElementById('root')).render(
React.createElement(Demo)
);
</script>
</body>
</html>在保存 HTML 的目錄啟動靜態 server;Python 需已安裝:
python3 -m http.server 4173 --bind 127.0.0.1開啟 http://127.0.0.1:4173/。驗收:按鈕初始顯示 Clicked 0 times;每次點擊加一;Console 無 module resolution 或 invalid hook call 錯誤;Emotion style 已注入。
這個版本的 Tonic UI 會要求 react-focus-lock/dist/cjs。本次瀏覽器 smoke 中,該 CJS 路徑的轉換產物在 Babel helper interop 發生 TypeError: x is not a function。範例將 react-focus-lock 保留 external,並由 import map 將其 CJS specifier 解析到同套件固定版本 2.13.7 的 ESM root。
這是明確的 entry routing,不是忽略例外,也不是移除 focus-lock。Production lock/graph 必須記錄這個 mapping,並驗證 modal/focus restoration 等實際功能後才能發布;Button smoke 不代表所有 focus-lock 行為已通過。
同樣必須映射 @emotion/styled/base;只映射 @emotion/styled 不會自動解析這個 subpath。
- 在真實 Chromium 載入本頁所附 HTML,使用公開 esm.sh。
- 按鈕由
Clicked 0 times點擊後變成Clicked 1 times。 - 觀察到一個 Emotion style element;修正後該次頁面沒有 browser error。
- 這是載入/hook/click smoke,尚未完成 theme 切換、modal、charts 或 production release gates。
HTTP 建置探測可另外執行:
curl --fail --show-error --silent --max-time 120 -i \
'https://esm.sh/@tonic-ui/react@3.0.0-alpha.1?target=es2022&external=react,react-dom,@emotion/react,@emotion/styled'HTTP 200 不是 UI 驗收。還需追蹤 wrapper 的 imports 與實際 module graph,並觀察元件互動。
直接使用已發布版本時可跳過此節。需要修改套件時,在 Tonic UI repository 根目錄執行:
yarn install --immutable
yarn build-public
yarn lint
yarn test環境需已有 repository 指定的 Yarn 4.17.1 與相容 Node。build-public 使用現有 Lerna 流程建置非 private workspaces;lint/test 依現有 root scripts 執行。
現有 React package 產出 dist/esm、dist/cjs 與 types,Rollup 將 bare imports 保留為 external。新增 exports 時,必須保留既有可用入口,並驗證 internal subpaths;不要為了 CDN 而任意公開整個 source tree。
本地 build 不會自動讓公開 esm.sh 取得修改。 修改後的版本必須經既有、獲授權的 package release 流程發布,或在內部 registry 驗證。本文不執行 npm publish、commit、push 或 deploy。