diff --git a/.changeset/fix-code-highlighter-languages.md b/.changeset/fix-code-highlighter-languages.md new file mode 100644 index 00000000..64e5b2b1 --- /dev/null +++ b/.changeset/fix-code-highlighter-languages.md @@ -0,0 +1,6 @@ +--- +'@modelscope-studio/frontend': patch +'modelscope_studio': patch +--- + +fix: `antdx.CodeHighlighter` fails to load Prism languages (`Failed to resolve module specifier`) and renders code without highlighting in the production build diff --git a/.changeset/share-ant-design-x.md b/.changeset/share-ant-design-x.md new file mode 100644 index 00000000..fcb31771 --- /dev/null +++ b/.changeset/share-ant-design-x.md @@ -0,0 +1,6 @@ +--- +'@modelscope-studio/frontend': patch +'modelscope_studio': patch +--- + +fix: share `@ant-design/x` across components instead of bundling a copy into each of them diff --git a/.changeset/share-svelte-runtime.md b/.changeset/share-svelte-runtime.md new file mode 100644 index 00000000..4280c8d8 --- /dev/null +++ b/.changeset/share-svelte-runtime.md @@ -0,0 +1,6 @@ +--- +'@modelscope-studio/frontend': patch +'modelscope_studio': patch +--- + +fix: components stop updating after the first render in the production build, e.g. revisited tabs or menu items no longer switch the content and `ms.AutoLoading` no longer shows up diff --git a/frontend/defineConfig.js b/frontend/defineConfig.js index fc7f98a9..c87ccfea 100644 --- a/frontend/defineConfig.js +++ b/frontend/defineConfig.js @@ -1,6 +1,6 @@ import react from '@vitejs/plugin-react'; -import { ModelScopeStudioVitePlugin } from './plugin.js'; +import { ModelScopeStudioVitePlugin } from './plugins/index.js'; /** * @type {(options:{ external?: boolean | { excludes:string[] } }) => any} diff --git a/frontend/fixtures.d.ts b/frontend/fixtures.d.ts index a6dca3ab..869f40e9 100644 --- a/frontend/fixtures.d.ts +++ b/frontend/fixtures.d.ts @@ -2,6 +2,8 @@ declare module 'virtual:component-loader' { export const load_component; } +declare module 'virtual:ms-svelte-runtime' {} + declare module '*?worker' { const workerConstructor: { new (options?: { name?: string }): Worker; diff --git a/frontend/plugin.js b/frontend/plugin.js deleted file mode 100644 index 89b96b32..00000000 --- a/frontend/plugin.js +++ /dev/null @@ -1,243 +0,0 @@ -import { - parseSync, - transformFromAstSync, - traverse, - types as t, -} from '@babel/core'; -import path from 'node:path'; -import url from 'node:url'; -import { esmExternalRequirePlugin } from 'vite'; - -// Each entry mirrors how the global is exposed in -// `frontend/svelte-preprocess-react/inject.ts`: -// - namespace: true -> registered via `import * as X from 'mod'` -// (`window.ms_globals.` is the namespace object) -// - namespace: false -> registered via `import X from 'mod'` (or a single -// named binding stored directly), so -// `window.ms_globals.` already is the value. -const baseGlobals = { - react: { ref: 'window.ms_globals.React', namespace: false }, - 'react-dom': { ref: 'window.ms_globals.ReactDOM', namespace: false }, - 'react-dom/client': { - ref: 'window.ms_globals.ReactDOMClient', - namespace: false, - }, - antd: { ref: 'window.ms_globals.antd', namespace: true }, - antdx: { ref: 'window.ms_globals.antdx', namespace: true }, - '@ant-design/cssinjs': { - ref: 'window.ms_globals.antdCssinjs', - namespace: true, - }, - '@ant-design/icons': { - ref: 'window.ms_globals.antdIcons', - namespace: true, - }, - - '@svelte-preprocess-react/react-contexts': { - ref: 'window.ms_globals.internalReactContexts', - namespace: true, - }, - dayjs: { ref: 'window.ms_globals.dayjs', namespace: false }, - '@utils/createItemsContext': { - ref: 'window.ms_globals.createItemsContext', - namespace: true, - }, - '@globals/components': { - ref: 'window.ms_globals.components', - namespace: true, - }, - '@monaco-editor/loader': { - ref: 'window.ms_globals.monacoLoader', - namespace: false, - }, -}; - -const dirname = path.dirname(url.fileURLToPath(import.meta.url)); - -/** - * `t.identifier` only accepts a valid identifier name, so a dotted global path - * like `window.ms_globals.React` has to be built as a member expression. - */ -function createGlobalExpression(variable) { - const [object, ...properties] = variable.split('.'); - return properties.reduce( - (expression, property) => - t.memberExpression(expression, t.identifier(property)), - t.identifier(object) - ); -} - -/** - * Access the imported/exported name on the global expression, string literal - * names (eg: `import { 'a-b' as c } from 'react'`) must be computed. - */ -function createGlobalMemberExpression(variable, property) { - return t.memberExpression( - createGlobalExpression(variable), - t.cloneNode(property), - t.isStringLiteral(property) - ); -} - -function generateSveltePreprocessReactAliases() { - const baseDir = 'svelte-preprocess-react'; - const baseAlias = { - '@svelte-preprocess-react': path.resolve(dirname, baseDir), - }; - - const moduleAliases = {}; - - return { - ...baseAlias, - ...moduleAliases, - }; -} - -/** - * @type {(options:{ external?: { excludes:string[] } | boolean }) => import('vite').Plugin[]} - */ -export const ModelScopeStudioVitePlugin = ({ external = true } = {}) => { - const globals = external?.excludes - ? Object.keys(baseGlobals).reduce((aliases, name) => { - if (!external.excludes.includes(name)) { - aliases[name] = baseGlobals[name]; - } - return aliases; - }, {}) - : baseGlobals; - const externalNames = external ? Object.keys(globals) : []; - return [ - // Rolldown handles `require()` in bundled CJS modules through a - // runtime shim that throws in the browser. `esmExternalRequirePlugin` fixes - // that at the correct layer: it rewrites each such `require` into an ES - // import (via a virtual facade `import * as m from ''; module.exports - // = m`), so the shim never gets called and the resulting ES imports flow - // through our own renderChunk transform below, which turns them into - // `window.ms_globals.*` references alongside the code's native imports. - // - // The plugin owns the entire external list, so nothing gets pinned onto - // `build.rolldownOptions.external`; duplicating the list there would let - // the top-level `external` win during resolution and silently disable this - // plugin (rolldown docs). - ...(externalNames.length - ? [esmExternalRequirePlugin({ external: externalNames })] - : []), - { - name: 'modelscope-studio-vite-plugin', - config(userConfig, { command }) { - const isBuild = command === 'build'; - if (isBuild) { - userConfig.define = { - ...userConfig.define, - 'process.env.NODE_ENV': JSON.stringify('production'), - }; - userConfig.build ??= {}; - } - - userConfig.resolve ??= {}; - userConfig.resolve.alias = { - ...(userConfig.resolve.alias || {}), - '@utils': path.resolve(dirname, 'utils'), - '@globals': path.resolve(dirname, 'globals'), - ...generateSveltePreprocessReactAliases(), - }; - }, - renderChunk(code, chunk) { - const id = chunk.fileName; - if ( - ['.jsx', '.js', '.cjs', '.esm', '.tsx', '.ts'].some((ext) => - id.endsWith(ext) - ) - ) { - const ast = parseSync(code, { - sourceType: 'module', - }); - traverse(ast, { - Program: { - enter(enterPath) { - enterPath.traverse({ - ExportNamedDeclaration(nodePath) { - const source = nodePath.node.source?.value; - const entry = globals[source]; - if (!entry) { - return; - } - const { specifiers } = nodePath.node; - - const decls = specifiers.map((specifier) => { - return t.variableDeclarator( - specifier.local, - createGlobalMemberExpression(entry.ref, specifier.local) - ); - }); - nodePath.insertBefore( - t.variableDeclaration('const', decls) - ); - nodePath.insertAfter( - t.exportNamedDeclaration(null, specifiers) - ); - nodePath.remove(); - }, - ImportDeclaration(nodePath) { - const source = nodePath.node.source.value; - const entry = globals[source]; - - if (!entry) { - return; - } - - const { specifiers } = nodePath.node; - // eg: import "react"; - if (specifiers.length === 0) { - nodePath.remove(); - return; - } - const decls = specifiers.map((specifier) => { - switch (specifier.type) { - case 'ImportDefaultSpecifier': - // For namespace-style globals, the default export - // lives at `.default`. For value-style globals, - // `` itself already is the default value. - return t.variableDeclarator( - specifier.local, - entry.namespace - ? createGlobalMemberExpression( - entry.ref, - t.identifier('default') - ) - : createGlobalExpression(entry.ref) - ); - case 'ImportSpecifier': - return t.variableDeclarator( - specifier.local, - createGlobalMemberExpression( - entry.ref, - specifier.imported - ) - ); - case 'ImportNamespaceSpecifier': - return t.variableDeclarator( - specifier.local, - createGlobalExpression(entry.ref) - ); - default: - throw new Error( - `Unsupported import specifier type ${specifier.type}` - ); - } - }); - nodePath.insertAfter(t.variableDeclaration('const', decls)); - nodePath.remove(); - }, - }); - }, - }, - }); - const transformed = transformFromAstSync(ast); - - return transformed.code; - } - }, - }, - ]; -}; diff --git a/frontend/plugins/config.js b/frontend/plugins/config.js new file mode 100644 index 00000000..fc0b7900 --- /dev/null +++ b/frontend/plugins/config.js @@ -0,0 +1,39 @@ +import path from 'node:path'; +import url from 'node:url'; + +const frontendDir = path.resolve( + path.dirname(url.fileURLToPath(import.meta.url)), + '..' +); + +/** + * The source aliases (keep in sync with `paths` of the root `tsconfig.json`) + * and the production mode of the build. + * + * @type {() => import('vite').Plugin} + */ +export function configPlugin() { + return { + name: 'modelscope-studio-config', + config(userConfig, { command }) { + if (command === 'build') { + userConfig.define = { + ...userConfig.define, + 'process.env.NODE_ENV': JSON.stringify('production'), + }; + userConfig.build ??= {}; + } + + userConfig.resolve ??= {}; + userConfig.resolve.alias = { + ...(userConfig.resolve.alias || {}), + '@utils': path.resolve(frontendDir, 'utils'), + '@globals': path.resolve(frontendDir, 'globals'), + '@svelte-preprocess-react': path.resolve( + frontendDir, + 'svelte-preprocess-react' + ), + }; + }, + }; +} diff --git a/frontend/plugins/externalGlobals.js b/frontend/plugins/externalGlobals.js new file mode 100644 index 00000000..0ebe137d --- /dev/null +++ b/frontend/plugins/externalGlobals.js @@ -0,0 +1,145 @@ +import { + parseSync, + transformFromAstSync, + traverse, + types as t, +} from '@babel/core'; +import { esmExternalRequirePlugin } from 'vite'; + +/** + * `t.identifier` only accepts a valid identifier name, so a dotted global path + * like `window.ms_globals.React` has to be built as a member expression. + */ +function createGlobalExpression(variable) { + const [object, ...properties] = variable.split('.'); + return properties.reduce( + (expression, property) => + t.memberExpression(expression, t.identifier(property)), + t.identifier(object) + ); +} + +/** + * Access the imported/exported name on the global expression, string literal + * names (eg: `import { 'a-b' as c } from 'react'`) must be computed. + */ +function createGlobalMemberExpression(variable, property) { + return t.memberExpression( + createGlobalExpression(variable), + t.cloneNode(property), + t.isStringLiteral(property) + ); +} + +/** + * Keep the shared libraries external and turn their imports into + * `window.ms_globals.*` references, see `./globals.js`. + * + * @type {(options: { globals: Record }) => import('vite').Plugin[]} + */ +export function externalGlobalsPlugin({ globals }) { + const externalNames = Object.keys(globals); + if (!externalNames.length) { + return []; + } + return [ + // Rolldown handles `require()` in bundled CJS modules through a + // runtime shim that throws in the browser. `esmExternalRequirePlugin` fixes + // that at the correct layer: it rewrites each such `require` into an ES + // import (via a virtual facade `import * as m from ''; module.exports + // = m`), so the shim never gets called and the resulting ES imports flow + // through our own renderChunk transform below, which turns them into + // `window.ms_globals.*` references alongside the code's native imports. + // + // The plugin owns the entire external list, so nothing gets pinned onto + // `build.rolldownOptions.external`; duplicating the list there would let + // the top-level `external` win during resolution and silently disable this + // plugin (rolldown docs). + esmExternalRequirePlugin({ external: externalNames }), + { + name: 'modelscope-studio-external-globals', + renderChunk(code, chunk) { + const id = chunk.fileName; + if ( + !['.jsx', '.js', '.cjs', '.esm', '.tsx', '.ts'].some((ext) => + id.endsWith(ext) + ) + ) { + return; + } + const ast = parseSync(code, { + sourceType: 'module', + }); + traverse(ast, { + ExportNamedDeclaration(nodePath) { + const source = nodePath.node.source?.value; + const entry = globals[source]; + if (!entry) { + return; + } + const { specifiers } = nodePath.node; + + const decls = specifiers.map((specifier) => { + return t.variableDeclarator( + specifier.local, + createGlobalMemberExpression(entry.ref, specifier.local) + ); + }); + nodePath.insertBefore(t.variableDeclaration('const', decls)); + nodePath.insertAfter(t.exportNamedDeclaration(null, specifiers)); + nodePath.remove(); + }, + ImportDeclaration(nodePath) { + const source = nodePath.node.source.value; + const entry = globals[source]; + + if (!entry) { + return; + } + + const { specifiers } = nodePath.node; + // eg: import "react"; + if (specifiers.length === 0) { + nodePath.remove(); + return; + } + const decls = specifiers.map((specifier) => { + switch (specifier.type) { + case 'ImportDefaultSpecifier': + // For namespace-style globals, the default export lives at + // `.default`. For value-style globals, `` itself + // already is the default value. + return t.variableDeclarator( + specifier.local, + entry.namespace + ? createGlobalMemberExpression( + entry.ref, + t.identifier('default') + ) + : createGlobalExpression(entry.ref) + ); + case 'ImportSpecifier': + return t.variableDeclarator( + specifier.local, + createGlobalMemberExpression(entry.ref, specifier.imported) + ); + case 'ImportNamespaceSpecifier': + return t.variableDeclarator( + specifier.local, + createGlobalExpression(entry.ref) + ); + default: + throw new Error( + `Unsupported import specifier type ${specifier.type}` + ); + } + }); + nodePath.insertAfter(t.variableDeclaration('const', decls)); + nodePath.remove(); + }, + }); + return transformFromAstSync(ast).code; + }, + }, + ]; +} diff --git a/frontend/plugins/globals.js b/frontend/plugins/globals.js new file mode 100644 index 00000000..5cb2d5fc --- /dev/null +++ b/frontend/plugins/globals.js @@ -0,0 +1,170 @@ +// Libraries shared through `window.ms_globals`. +// +// `ms.Application` (the provider, built with `external: false`) bundles them +// and registers them in `frontend/svelte-preprocess-react/inject.ts`. Every +// other component (the consumers) keeps them external and reads them from +// `window.ms_globals` instead, see `./externalGlobals.js`. +// +// `inject.ts` is the single source of truth, the table is read from the +// `window.ms_globals = { ... }` object there. Every property whose value is an +// imported binding is shared under the specifier it is imported from: +// - `import * as X from 'mod'` -> namespace: `window.ms_globals.` is the +// namespace object of `mod`; +// - `import X from 'mod'` -> value: `window.ms_globals.` already is +// the default export of `mod`. +// Import the module with the exact specifier the consumers use (`'@utils/...'`, +// not a relative path). A named import is not a module, it is only shared when +// the property is annotated with the specifier consumers import it from: +// // @external @monaco-editor/loader +// monacoLoader: loader, +// +// Consumers only read these globals lazily (after `importComponent` awaited +// `window.ms_globals.initializePromise`). The Svelte runtime is needed as soon +// as a component module is evaluated, so it is shared differently, see +// `./sharedSvelteRuntime.js`. + +import { parseSync, traverse } from '@babel/core'; +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +const INJECT_FILE = path.resolve( + path.dirname(url.fileURLToPath(import.meta.url)), + '../svelte-preprocess-react/inject.ts' +); +const EXTERNAL_ANNOTATION_RE = /@external\s+(\S+)/; + +/** + * @typedef {{ ref: string; namespace: boolean }} SharedGlobal + */ + +function isMsGlobals(node) { + return ( + node.type === 'MemberExpression' && + !node.computed && + node.object.type === 'Identifier' && + node.object.name === 'window' && + node.property.type === 'Identifier' && + node.property.name === 'ms_globals' + ); +} + +// `ref` is a dotted path, so only identifier keys can be shared. +function getPropertyName(property) { + if (!property.computed && property.key.type === 'Identifier') { + return property.key.name; + } +} + +function getExternalAnnotation(property) { + for (const comment of property.leadingComments ?? []) { + const match = comment.value.match(EXTERNAL_ANNOTATION_RE); + if (match) { + return match[1]; + } + } +} + +/** + * @returns {Record} + */ +function readSharedGlobals() { + const fail = (message) => { + throw new Error(`[modelscope-studio] ${INJECT_FILE}: ${message}`); + }; + const ast = parseSync(fs.readFileSync(INJECT_FILE, 'utf-8'), { + filename: INJECT_FILE, + babelrc: false, + configFile: false, + sourceType: 'module', + parserOpts: { plugins: ['typescript'] }, + }); + + // local binding -> how it is imported + const imports = new Map(); + let globalsObject; + traverse(ast, { + ImportDeclaration({ node }) { + if (node.importKind === 'type') { + return; + } + for (const specifier of node.specifiers) { + if (specifier.importKind === 'type') { + continue; + } + imports.set(specifier.local.name, { + source: node.source.value, + kind: specifier.type, + }); + } + }, + AssignmentExpression({ node }) { + if ( + node.operator === '=' && + isMsGlobals(node.left) && + node.right.type === 'ObjectExpression' + ) { + if (globalsObject) { + fail('`window.ms_globals` is assigned more than once.'); + } + globalsObject = node.right; + } + }, + }); + if (!globalsObject) { + fail('no `window.ms_globals = { ... }` assignment found.'); + } + + /** @type {Record} */ + const globals = {}; + for (const property of globalsObject.properties) { + if (property.type !== 'ObjectProperty') { + continue; + } + const name = getPropertyName(property); + const binding = + property.value.type === 'Identifier' && imports.get(property.value.name); + if (!name || !binding) { + continue; + } + const annotation = getExternalAnnotation(property); + let specifier; + if (binding.kind === 'ImportSpecifier') { + if (!annotation) { + continue; + } + specifier = annotation; + } else { + specifier = annotation ?? binding.source; + if (specifier.startsWith('.')) { + fail( + `"${name}" is imported from the relative path "${specifier}", import it with the specifier the consumers use.` + ); + } + } + if (Object.hasOwn(globals, specifier)) { + fail(`"${specifier}" is shared more than once.`); + } + globals[specifier] = { + ref: `window.ms_globals.${name}`, + namespace: binding.kind === 'ImportNamespaceSpecifier', + }; + } + return globals; +} + +/** + * @type {Record} + */ +export const sharedGlobals = readSharedGlobals(); + +/** + * The shared globals a consumer reads, minus the ones it bundles itself. + * + * @type {(excludes: string[]) => Record} + */ +export function resolveGlobals(excludes) { + return Object.fromEntries( + Object.entries(sharedGlobals).filter(([name]) => !excludes.includes(name)) + ); +} diff --git a/frontend/plugins/index.js b/frontend/plugins/index.js new file mode 100644 index 00000000..a1aa17af --- /dev/null +++ b/frontend/plugins/index.js @@ -0,0 +1,39 @@ +// Vite plugins of every component build, see `frontend/defineConfig.js`. +// +// Each component is built on its own, and `external` decides its role: +// - `false`, the provider (only `ms.Application`): bundles the shared +// libraries and the Svelte runtime, and registers them on +// `window.ms_globals` (`frontend/svelte-preprocess-react/inject.ts`); +// - `true` (default) or `{ excludes }`, a consumer: reads them from +// `window.ms_globals` instead of bundling its own copy. `excludes` lists +// the ones it bundles itself (keys of `./globals.js`, or `'svelte'`). +// +// - `./globals.js` the shared libraries, read from `inject.ts` +// - `./externalGlobals.js` consumers read the shared libraries +// - `./sharedSvelteRuntime.js` consumers run on the provider's Svelte runtime +// - `./prismLanguages.js` fix of CodeHighlighter language loading +// - `./config.js` aliases and production mode + +import { configPlugin } from './config.js'; +import { externalGlobalsPlugin } from './externalGlobals.js'; +import { resolveGlobals } from './globals.js'; +import { prismLanguagesPlugin } from './prismLanguages.js'; +import { sharedSvelteRuntimePlugin } from './sharedSvelteRuntime.js'; + +/** + * @type {(options?: { external?: { excludes: string[] } | boolean }) => import('vite').Plugin[]} + */ +export const ModelScopeStudioVitePlugin = ({ external = true } = {}) => { + const consumer = !!external; + const excludes = (consumer && external.excludes) || []; + return [ + sharedSvelteRuntimePlugin({ + consumer: consumer && !excludes.includes('svelte'), + }), + prismLanguagesPlugin(), + ...externalGlobalsPlugin({ + globals: consumer ? resolveGlobals(excludes) : {}, + }), + configPlugin(), + ]; +}; diff --git a/frontend/plugins/prismLanguages.js b/frontend/plugins/prismLanguages.js new file mode 100644 index 00000000..192f18b8 --- /dev/null +++ b/frontend/plugins/prismLanguages.js @@ -0,0 +1,65 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +// `@ant-design/x`'s CodeHighlighter loads its languages with +// import(`react-syntax-highlighter/dist/esm/languages/prism/${lang}`). The +// bundler only expands relative template imports, so the bare specifier reaches +// the browser as is and fails to resolve. The same module lazy-loads the full +// Prism build as well (`refractor/all`, which holds every language), so mapping +// the languages statically costs no extra size. +const LANGUAGES_DIR = 'react-syntax-highlighter/dist/esm/languages/prism'; +const LANGUAGE_IMPORT_RE = + /import\(\s*`react-syntax-highlighter\/dist\/esm\/languages\/prism\/\$\{([^`}]+)\}`\s*\)/g; +const NON_LANGUAGE_FILES = ['index.js', 'supported-languages.js']; + +/** + * @type {() => import('vite').Plugin} + */ +export function prismLanguagesPlugin() { + return { + name: 'modelscope-studio-prism-languages', + apply: 'build', + async transform(code, id) { + if (!code.includes(`${LANGUAGES_DIR}/\${`)) { + return; + } + const transformed = code.replace( + LANGUAGE_IMPORT_RE, + (_, lang) => `__ms_load_prism_language(${lang})` + ); + if (transformed === code) { + return; + } + const resolved = await this.resolve(`${LANGUAGES_DIR}/index.js`, id); + if (!resolved) { + return; + } + const dir = path.dirname(resolved.id); + const languages = fs + .readdirSync(dir) + .filter( + (file) => file.endsWith('.js') && !NON_LANGUAGE_FILES.includes(file) + ) + .map((file) => file.slice(0, -'.js'.length)); + return { + code: [ + ...languages.map( + (lang, i) => + `import __ms_prism_language_${i} from ${JSON.stringify(path.join(dir, `${lang}.js`))};` + ), + `const __ms_prism_languages = { ${languages + .map( + (lang, i) => `${JSON.stringify(lang)}: __ms_prism_language_${i}` + ) + .join(', ')} };`, + 'const __ms_load_prism_language = (lang) =>', + ' Object.hasOwn(__ms_prism_languages, lang)', + ' ? Promise.resolve({ default: __ms_prism_languages[lang] })', + ' : Promise.reject(new Error(`Unknown Prism language: ${lang}`));', + transformed, + ].join('\n'), + map: null, + }; + }, + }; +} diff --git a/frontend/plugins/sharedSvelteRuntime.js b/frontend/plugins/sharedSvelteRuntime.js new file mode 100644 index 00000000..2019e41b --- /dev/null +++ b/frontend/plugins/sharedSvelteRuntime.js @@ -0,0 +1,218 @@ +import path from 'node:path'; +import url from 'node:url'; + +// `gradio cc dev` hands every custom component the same `import("svelte")`, so +// they all run on one Svelte runtime. `gradio cc build` (bridge generation, +// Gradio >= 6.9) instead bundles a private runtime into each component. Our +// components are not independent though: the React tree created by the first +// one watches the `$state` props of all the others +// (`svelte-preprocess-react/internal/Bridge.svelte.ts`), and a runtime never +// tracks signals owned by another one, so prop updates stop re-rendering in the +// build (visited tabs never switch back, AutoLoading never shows up). +// +// To restore the dev behaviour: +// - the provider (`ms.Application`) imports `virtual:ms-svelte-runtime` from +// `inject.ts`, which bundles every entry below and hands them over through +// `window.ms_globals.svelteRuntime`; +// - the consumers (every other component) resolve their `svelte` imports to +// facades that wait for it (top-level await) and re-export its bindings. +// +// Unlike `./globals.js`, the imports cannot be turned into plain +// `window.ms_globals.*` reads: the Svelte compiler and Gradio's +// `svelte_runtime_entry.js` use the runtime as soon as a component module is +// evaluated, and Gradio does not evaluate `ms.Application` first. +// +// When Svelte adds a client entry, add it to `sharedEntries` (or +// `flagEntries`), the build fails on any unlisted one a consumer imports. + +const RUNTIME_ID = 'virtual:ms-svelte-runtime'; +const RESOLVED_RUNTIME_ID = '\0' + RUNTIME_ID; +// `window.ms_globals.svelteRuntime`, shared by the provider and the consumers. +const RUNTIME_STATE_ID = '\0ms-svelte-runtime-state'; +const FACADE_PREFIX = '\0ms-svelte-facade:'; + +// Every public client entry a component may import. The provider registers all +// of them, so whichever entry a consumer needs comes from the same runtime. +const sharedEntries = [ + 'svelte', + 'svelte/animate', + 'svelte/attachments', + 'svelte/easing', + 'svelte/events', + 'svelte/internal/client', + 'svelte/legacy', + 'svelte/motion', + 'svelte/reactivity', + 'svelte/reactivity/window', + 'svelte/store', + 'svelte/transition', +]; +// Side-effect only entries that switch a flag of the runtime on. Consumers +// call the flag function themselves, so a flag is only on when some component +// asks for it, as in dev. +const flagEntries = { + 'svelte/internal/flags/async': 'enable_async_mode_flag', + 'svelte/internal/flags/legacy': 'enable_legacy_mode_flag', + 'svelte/internal/flags/tracing': 'enable_tracing_mode_flag', +}; +// Side-effect only entry that registers the version on `window.__svelte`, the +// provider already does it. +const DISCLOSE_VERSION_ENTRY = 'svelte/internal/disclose-version'; + +// Entries that never reach the client runtime. +const serverEntries = [ + 'svelte/compiler', + 'svelte/server', + 'svelte/internal/server', +]; + +function isSvelteEntry(source) { + return source === 'svelte' || source.startsWith('svelte/'); +} + +function isSharedEntry(source) { + return ( + sharedEntries.includes(source) || + Object.hasOwn(flagEntries, source) || + source === DISCLOSE_VERSION_ENTRY + ); +} + +const runtimeStateCode = ` +const ms_globals = (window.ms_globals ??= {}); +if (!ms_globals.svelteRuntime) { + let resolve; + const promise = new Promise((r) => { + resolve = r; + }); + ms_globals.svelteRuntime = { promise, resolve }; +} +export default ms_globals.svelteRuntime; +`; +const importRuntimeState = `import runtime from ${JSON.stringify(RUNTIME_STATE_ID)};`; + +const moduleExportNames = new Map(); +// The facade has to list the bindings it re-exports, read them from the module +// the consumer would have bundled. +function getModuleExportNames(file) { + if (!moduleExportNames.has(file)) { + moduleExportNames.set( + file, + import(url.pathToFileURL(file).href).then((m) => Object.keys(m)) + ); + } + return moduleExportNames.get(file); +} + +/** + * @type {(context: import('rollup').PluginContext, id: string) => Promise} + */ +async function loadProvider(context, id) { + const legacyFlag = await context.resolve('svelte/internal/flags/legacy', id); + // The "shared" Gradio generation (<= 6.8) already points `svelte` at the + // app's own runtime. + if (!legacyFlag || legacyFlag.external) { + return 'export {};'; + } + // Not a public entry, but the module holding the flags that the + // `svelte/internal/flags/*` entries switch on. + const flagsFile = path.join(path.dirname(legacyFlag.id), 'index.js'); + return [ + importRuntimeState, + `import ${JSON.stringify(DISCLOSE_VERSION_ENTRY)};`, + ...sharedEntries.map( + (entry, i) => `import * as m${i} from ${JSON.stringify(entry)};` + ), + `import * as flags from ${JSON.stringify(flagsFile)};`, + `runtime.resolve({ modules: { ${sharedEntries + .map((entry, i) => `${JSON.stringify(entry)}: m${i}`) + .join(', ')} }, flags });`, + ].join('\n'); +} + +/** + * @type {(source: string, file: string | undefined) => Promise} + */ +async function loadFacade(source, file) { + const lines = [ + importRuntimeState, + 'const { modules, flags } = await runtime.promise;', + ]; + if (Object.hasOwn(flagEntries, source)) { + lines.push(`flags.${flagEntries[source]}();`); + } else if (source !== DISCLOSE_VERSION_ENTRY) { + const names = await getModuleExportNames(file); + lines.push(`const m = modules[${JSON.stringify(source)}];`); + names.forEach((name, i) => { + lines.push(`const e${i} = m[${JSON.stringify(name)}];`); + }); + lines.push( + `export { ${names.map((name, i) => `e${i} as ${name}`).join(', ')} };` + ); + } + return lines.join('\n'); +} + +/** + * Share the provider's Svelte runtime with every consumer in the build. + * + * @type {(options: { consumer: boolean }) => import('vite').Plugin} + */ +export function sharedSvelteRuntimePlugin({ consumer }) { + // facade source -> the module the consumer would have bundled + const facadeTargets = new Map(); + let isBuild = false; + return { + name: 'modelscope-studio-shared-svelte-runtime', + enforce: 'pre', + configResolved(config) { + isBuild = config.command === 'build'; + }, + async resolveId(source, importer, options) { + if (source === RUNTIME_ID) { + return RESOLVED_RUNTIME_ID; + } + if (source === RUNTIME_STATE_ID) { + return source; + } + if ( + !isBuild || + !consumer || + importer === RESOLVED_RUNTIME_ID || + !isSvelteEntry(source) || + serverEntries.includes(source) + ) { + return; + } + if (!isSharedEntry(source)) { + this.error( + `"${source}" is not shared with ms.Application, add it to \`sharedEntries\` in ${url.fileURLToPath(import.meta.url)}.` + ); + } + const resolved = await this.resolve(source, importer, { + ...options, + skipSelf: true, + }); + // The "shared" Gradio generation (<= 6.8) already points `svelte` at the + // app's own runtime. + if (!resolved || resolved.external) { + return resolved; + } + facadeTargets.set(source, resolved.id); + return FACADE_PREFIX + source; + }, + load(id) { + if (id === RUNTIME_STATE_ID) { + return runtimeStateCode; + } + if (id === RESOLVED_RUNTIME_ID) { + // dev already runs every component on one runtime + return isBuild ? loadProvider(this, id) : 'export {};'; + } + if (id.startsWith(FACADE_PREFIX)) { + const source = id.slice(FACADE_PREFIX.length); + return loadFacade(source, facadeTargets.get(source)); + } + }, + }; +} diff --git a/frontend/svelte-preprocess-react/inject.ts b/frontend/svelte-preprocess-react/inject.ts index aa53320b..0ba39e56 100644 --- a/frontend/svelte-preprocess-react/inject.ts +++ b/frontend/svelte-preprocess-react/inject.ts @@ -1,6 +1,6 @@ -import * as internalReactContexts from './react-contexts'; import type { loader as monacoLoader } from '@monaco-editor/react'; import { loader } from '@monaco-editor/react'; +import * as internalReactContexts from '@svelte-preprocess-react/react-contexts'; import React from 'react'; import ReactDOM from 'react-dom'; import ReactDOMClient from 'react-dom/client'; @@ -14,6 +14,10 @@ import dayjs from 'dayjs'; import { noop } from 'lodash-es'; import './polyfills'; +// Provide the Svelte runtime shared by all the other components in the build, +// see `frontend/plugins/sharedSvelteRuntime.js`. +// eslint-disable-next-line import/no-unresolved -- virtual module of the plugin +import 'virtual:ms-svelte-runtime'; import type { TreeNode } from './internal/types'; @@ -52,12 +56,21 @@ declare global { // globals components: typeof globalComponents; + + // shared svelte runtime, see `frontend/plugins/sharedSvelteRuntime.js` + svelteRuntime?: { + promise: Promise; + resolve: (runtime: unknown) => void; + }; }; } } window.ms_globals ??= {} as typeof window.ms_globals; +// The shared libraries of the build are read from this object, see +// `frontend/plugins/globals.js`. Import them with the specifier the other +// components use. window.ms_globals = { dispatch: noop, initialize: noop, @@ -75,6 +88,7 @@ window.ms_globals = { autokey: 0, loadingKey: 0, sharedRoot: undefined, + // @external @monaco-editor/loader monacoLoader: loader, monacoLoaderPromise: null, // render items