Skip to content

Commit 1f3b650

Browse files
committed
fix(framework-react-lynx-web): emit lynx static assets at output root
The `.web.bundle` embeds asset references as relative paths like `static/image/foo.png`. web-core hands those strings to the shadow DOM unchanged, so the browser resolves them against `iframe.html` — producing root-level `/static/image/foo.png` requests. Previously we copied them under `/lynx-bundles/static/image/`, which 404ed once `storybook-static/` was deployed behind a plain static server. - Build: `output.copy` now writes `static/**` to the output root (alongside storybook's own `static/{js,css,wasm}`, which don't overlap with lynx's `static/{image,font,svg}`). - Dev: proxy `/static/{image,font,svg}` to rspeedy so the same resolution works through the in-process dev server. Also splits `preview.ts` into a sync render entry and an async `preview-runtime.ts` side-effect entry. `@lynx-js/web-core` uses top-level await (WASM init), which promotes the importing module to async — and Storybook's `composeConfigs` reads `render` synchronously via `__webpack_require__`, yielding a Promise whose `.render` is undefined. The framework's render then loses to the web-components default and the story renders as "component annotation is missing". Keeping the render export in a sync module preserves the fields.
1 parent 6fdc536 commit 1f3b650

File tree

8 files changed

+463
-1691
lines changed

8 files changed

+463
-1691
lines changed

biome.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
2+
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
33
"assist": {
44
"includes": ["**", "!**/*.vue"],
55
"actions": { "source": { "organizeImports": "on" } }

packages/framework-react-lynx-web/build-config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ const config: BuildEntries = {
1111
exportEntries: ['./preview'],
1212
entryPoint: './src/preview.ts',
1313
},
14+
{
15+
exportEntries: ['./preview-runtime'],
16+
entryPoint: './src/preview-runtime.ts',
17+
dts: false,
18+
},
1419
],
1520
node: [
1621
{

packages/framework-react-lynx-web/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
"./preview": {
3636
"types": "./dist/preview.d.ts",
3737
"default": "./dist/preview.js"
38-
}
38+
},
39+
"./preview-runtime": "./dist/preview-runtime.js"
3940
},
4041
"files": [
4142
"dist/**/*",
@@ -70,7 +71,7 @@
7071
"storybook": "^10.1.0"
7172
},
7273
"engines": {
73-
"node": ">=18.0.0"
74+
"node": ">=20.6.0"
7475
},
7576
"publishConfig": {
7677
"access": "public"
@@ -80,7 +81,8 @@
8081
"./src/node/index.ts",
8182
"./src/index.ts",
8283
"./src/preset.ts",
83-
"./src/preview.ts"
84+
"./src/preview.ts",
85+
"./src/preview-runtime.ts"
8486
],
8587
"platform": "node"
8688
}

packages/framework-react-lynx-web/src/preset.ts

Lines changed: 113 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { existsSync } from 'node:fs'
22
import type { ServerResponse } from 'node:http'
3-
import { createRequire } from 'node:module'
4-
import { dirname, join, resolve } from 'node:path'
3+
import { join, resolve } from 'node:path'
54
import { fileURLToPath, pathToFileURL } from 'node:url'
65
import { mergeRsbuildConfig } from '@rsbuild/core'
76
import type { PresetProperty } from 'storybook/internal/types'
@@ -12,29 +11,37 @@ import type { FrameworkOptions, StorybookConfig } from './types'
1211
* own node_modules. This matters because pnpm can install multiple rspeedy
1312
* variants under different peer-dep contexts; the user's `pluginReactLynx`
1413
* (and the rest of their lynx.config) is bound to the rspeedy instance in
15-
* THEIR project. If we import the framework's own copy we get a different
16-
* module instance and the React Lynx loader/plugin chain silently no-ops
17-
* (manifesting as JSX parse errors).
14+
* THEIR project. If we imported the framework's own copy we would get a
15+
* different module instance and the React Lynx loader/plugin chain would
16+
* silently no-op (manifesting as JSX parse errors).
17+
*
18+
* Note: `createRequire(...).resolve('@lynx-js/rspeedy')` would fail with
19+
* `ERR_PACKAGE_PATH_NOT_EXPORTED` because the package's `exports."."`
20+
* defines only an `import` condition (no `require`). That's why we use the
21+
* two-arg form of `import.meta.resolve`, which walks the ESM resolver from
22+
* the user's project root — stable since Node 20.6 (engines.node enforces).
1823
*/
1924
async function importUserRspeedy(
2025
projectRoot: string,
2126
): Promise<typeof import('@lynx-js/rspeedy')> {
22-
// `require.resolve('@lynx-js/rspeedy')` fails: the package is pure-ESM
23-
// with no CJS `main`. We resolve `./package.json` (which IS in the
24-
// package's `exports` field) and then point at the stable ESM entry
25-
// `./dist/index.js`. We don't read the exports field at runtime because
26-
// it has been stable since 0.10.x and reading + parsing JSON adds nothing
27-
// beyond a runtime-detection illusion of safety.
28-
const require = createRequire(join(projectRoot, 'package.json'))
29-
const pkgJsonPath = require.resolve('@lynx-js/rspeedy/package.json')
30-
const entryAbs = join(dirname(pkgJsonPath), 'dist/index.js')
31-
return import(pathToFileURL(entryAbs).href)
27+
const parentUrl = pathToFileURL(join(projectRoot, 'package.json')).href
28+
const entryUrl = import.meta.resolve('@lynx-js/rspeedy', parentUrl)
29+
return import(entryUrl)
3230
}
3331

3432
export const previewAnnotations: PresetProperty<'previewAnnotations'> = async (
3533
input = [],
3634
) => {
37-
return [...input, fileURLToPath(import.meta.resolve('./preview'))]
35+
// Order matters: `preview-runtime` is async (top-level await from
36+
// @lynx-js/web-core's WASM init) and contributes no annotations, while
37+
// `preview` is sync and contributes `render` / `renderToCanvas`. Both are
38+
// appended AFTER the renderer's defaults so our render wins composeConfigs.
39+
// See the long note at the top of src/preview.ts for background.
40+
return [
41+
...input,
42+
fileURLToPath(import.meta.resolve('./preview-runtime')),
43+
fileURLToPath(import.meta.resolve('./preview')),
44+
]
3845
}
3946

4047
export const core: PresetProperty<'core'> = async (config, options) => {
@@ -84,7 +91,7 @@ export const staticDirs: PresetProperty<'staticDirs'> = async (
8491
if (lynxConfigPath) return input
8592

8693
const distDir = join(projectRoot, 'dist')
87-
const bundlePrefix = frameworkOptions.lynxBundlePrefix ?? '/lynx-bundles'
94+
const bundlePrefix = normalizeBundlePrefix(frameworkOptions.lynxBundlePrefix)
8895
return [...input, { from: distDir, to: bundlePrefix }]
8996
}
9097

@@ -123,6 +130,56 @@ interface RspeedyState {
123130
setupPromise?: Promise<void>
124131
}
125132

133+
const CSS_FILE_RE = /\.(?:css|scss|sass|less|styl|stylus)$/i
134+
135+
/**
136+
* Normalize a user-supplied URL prefix so downstream code can treat it
137+
* consistently: leading slash, no trailing slash (`/lynx-bundles`). The
138+
* proxy config needs the leading slash and `pathRewrite` pattern, while
139+
* `output.copy.to` wants a relative directory without the leading slash.
140+
* Accepting either form from the user keeps the DX forgiving.
141+
*/
142+
function normalizeBundlePrefix(raw: string | undefined): string {
143+
const value = (raw ?? '/lynx-bundles').trim() || '/lynx-bundles'
144+
const withLeading = value.startsWith('/') ? value : `/${value}`
145+
return withLeading.endsWith('/') && withLeading.length > 1
146+
? withLeading.slice(0, -1)
147+
: withLeading
148+
}
149+
150+
/**
151+
* Return true if the rebuild's modified file set contains at least one
152+
* style file. We walk the rspack Stats/MultiStats and collect
153+
* `compilation.modifiedFiles` (a `ReadonlySet<string>` of absolute paths
154+
* rspack populated from its watcher before the current rebuild ran). Any
155+
* source file change that ends in a CSS-family extension counts — TSX/TS
156+
* edits alone return `false` and let rsbuild's WebSocket HMR handle them.
157+
*/
158+
function hasCssChange(stats: unknown): boolean {
159+
// Duck-typed against Rspack.Stats | Rspack.MultiStats to avoid a
160+
// framework-level dependency on @rspack/core just for a type.
161+
const candidates: unknown[] = []
162+
const anyStats = stats as {
163+
stats?: unknown[]
164+
compilation?: { modifiedFiles?: ReadonlySet<string> }
165+
}
166+
if (Array.isArray(anyStats.stats)) {
167+
candidates.push(...anyStats.stats)
168+
} else {
169+
candidates.push(anyStats)
170+
}
171+
for (const s of candidates) {
172+
const modified = (
173+
s as { compilation?: { modifiedFiles?: ReadonlySet<string> } }
174+
).compilation?.modifiedFiles
175+
if (!modified) continue
176+
for (const file of modified) {
177+
if (CSS_FILE_RE.test(file)) return true
178+
}
179+
}
180+
return false
181+
}
182+
126183
const RSPEEDY_STATE_KEY = Symbol.for(
127184
'storybook-react-lynx-web-rsbuild.rspeedy.state',
128185
)
@@ -181,13 +238,19 @@ async function setupRspeedyDev(
181238
environment: ['web'],
182239
})
183240

184-
// Drive HMR reload broadcasts from rspeedy's compiler hook. We fire on
185-
// every non-first rebuild — the lynx web runtime has no per-asset HMR
186-
// (pluginReactLynx disables it for the `web` env, see lynx-stack
187-
// packages/rspeedy/plugin-react/src/entry.ts), so any source change
188-
// requires a full <lynx-view> reload regardless of which file changed.
189-
rspeedy.onDevCompileDone(({ isFirstCompile }) => {
241+
// Drive dev reload broadcasts from rspeedy's compiler hook, but only
242+
// for **CSS-ish** rebuilds. Background-thread JS already has real HMR
243+
// via rsbuild's standard WebSocket client embedded in the web bundle
244+
// (pluginReactLynx only skips its own HMR prepends for the `web` env
245+
// — see lynx-stack packages/rspeedy/plugin-react/src/entry.ts — not
246+
// rsbuild's normal ones), so broadcasting on every rebuild would
247+
// force-reload <lynx-view> and clobber the JS HMR's state
248+
// preservation. CSS edits, on the other hand, have no upstream HMR
249+
// path: we still need to ping preview.ts so it force-refetches the
250+
// template via the `?t=` cache-bust (see note in preview.ts).
251+
rspeedy.onDevCompileDone(({ isFirstCompile, stats }) => {
190252
if (isFirstCompile) return
253+
if (!hasCssChange(stats)) return
191254
for (const client of state.sseClients) {
192255
client.write('data: content-changed\n\n')
193256
}
@@ -242,7 +305,7 @@ export const rsbuildFinal: StorybookConfig['rsbuildFinal'] = async (
242305
typeof framework === 'string' ? {} : framework.options || {}
243306

244307
const projectRoot = resolveProjectRoot(options.configDir)
245-
const bundlePrefix = frameworkOptions.lynxBundlePrefix ?? '/lynx-bundles'
308+
const bundlePrefix = normalizeBundlePrefix(frameworkOptions.lynxBundlePrefix)
246309
const lynxConfig = findLynxConfig(
247310
projectRoot,
248311
frameworkOptions.lynxConfigPath,
@@ -269,6 +332,19 @@ export const rsbuildFinal: StorybookConfig['rsbuildFinal'] = async (
269332
changeOrigin: true,
270333
pathRewrite: { [`^${bundlePrefix}`]: '' },
271334
},
335+
// Lynx-emitted static assets (images, fonts, svg) are referenced
336+
// by the .web.bundle as *relative* URLs like `static/image/foo.png`.
337+
// web-core hands those strings to the DOM unchanged, so the browser
338+
// resolves them against `iframe.html` (root-absolute), NOT against
339+
// the bundle URL. We route those specific subpaths to rspeedy's
340+
// dev server to match how build-mode copies them to the root.
341+
//
342+
// Scoped to subpaths lynx actually uses to avoid colliding with
343+
// storybook's own `/static/js`, `/static/css`, `/static/wasm`,
344+
// which are served by the rsbuild dev middleware.
345+
'/static/image': { target: origin, changeOrigin: true },
346+
'/static/font': { target: origin, changeOrigin: true },
347+
'/static/svg': { target: origin, changeOrigin: true },
272348
'/.rspeedy': {
273349
target: origin,
274350
changeOrigin: true,
@@ -304,6 +380,18 @@ export const rsbuildFinal: StorybookConfig['rsbuildFinal'] = async (
304380
// Storybook output as static assets. The source directory comes from
305381
// `rspeedy.context.distPath` so a user-customized `output.distPath.root`
306382
// is honored.
383+
//
384+
// Copy layout:
385+
// - `.web.bundle` files → `lynx-bundles/` (isolated prefix; user's
386+
// `parameters.lynx.url` points here).
387+
// - `static/**` → output root, preserving the `static/` prefix. These
388+
// are referenced from inside the bundle as relative paths like
389+
// `static/image/foo.png`, which the browser resolves against
390+
// `iframe.html` → `/static/image/foo.png`. They MUST land at the
391+
// root, not under `lynx-bundles/`, otherwise image/font lookups 404.
392+
// (The collision surface with storybook's own emitted files is
393+
// empty by construction: storybook uses `static/{js,css,wasm}`
394+
// while lynx uses `static/{image,font,svg}`.)
307395
if (lynxConfig && !isDev) {
308396
const rspeedyDistDir = await runRspeedyBuild(projectRoot, lynxConfig)
309397

@@ -318,7 +406,7 @@ export const rsbuildFinal: StorybookConfig['rsbuildFinal'] = async (
318406
{
319407
from: 'static/**/*',
320408
context: rspeedyDistDir,
321-
to: `${bundlePrefix.slice(1)}/`,
409+
to: '',
322410
},
323411
],
324412
},
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Side-effect-only preview entry: registers the <lynx-view> custom element
2+
// (from `@lynx-js/web-core`), pulls in the full `@lynx-js/web-elements`
3+
// element set, and injects web-elements' layout CSS into document.head so
4+
// <lynx-view>'s shadow DOM can mirror it via inject-head-links.
5+
//
6+
// This file becomes an async module because `@lynx-js/web-core` ships with
7+
// top-level await (WASM init). That's why the render/renderToCanvas exports
8+
// live in the sibling `./preview.ts` instead of here — see the long note at
9+
// the top of that file. Keeping the two responsibilities in separate entries
10+
// lets the sync preview contribute exports to Storybook's composeConfigs
11+
// while this async entry runs its side effects independently.
12+
13+
// @ts-expect-error -- ?inline import resolves CSS to a string at build time
14+
import webElementsCSS from '@lynx-js/web-elements/index.css?inline'
15+
import '@lynx-js/web-core'
16+
import '@lynx-js/web-elements/all'
17+
18+
const webElementsLink = document.createElement('link')
19+
webElementsLink.rel = 'stylesheet'
20+
webElementsLink.href = URL.createObjectURL(
21+
new Blob([webElementsCSS], { type: 'text/css' }),
22+
)
23+
document.head.appendChild(webElementsLink)

0 commit comments

Comments
 (0)