|
| 1 | +# AGENTS.md |
| 2 | + |
| 3 | +Package-specific guidance for `storybook-react-lynx-web-rsbuild`. Supplements |
| 4 | +the repository-root `AGENTS.md` — only architecture and boundaries unique to |
| 5 | +this package live here. |
| 6 | + |
| 7 | +## What this package is |
| 8 | + |
| 9 | +A Storybook **framework** (in Storybook's |
| 10 | +builder/renderer/presets sense) that lets users develop ReactLynx components |
| 11 | +inside Storybook by rendering them through the `<lynx-view>` web-core |
| 12 | +runtime. We are *not* a builder, *not* a renderer, *not* an addon — we are |
| 13 | +the glue layer that picks both, registers presets, and threads the user's |
| 14 | +own Lynx toolchain through Storybook's dev/build pipeline. |
| 15 | + |
| 16 | +## Composition |
| 17 | + |
| 18 | +The framework is a thin layer over four upstream pieces. Knowing what each |
| 19 | +contributes is the fastest way to figure out where a change belongs. |
| 20 | + |
| 21 | +| Layer | Provided by | What it owns | |
| 22 | +| --- | --- | --- | |
| 23 | +| Builder | `storybook-builder-rsbuild` (workspace) | Bundling preview / manager assets, the dev server, HMR transport | |
| 24 | +| Renderer | `@storybook/web-components` | Story decoration, args reactivity, shadow-DOM-friendly default render (we override it but inherit everything else) | |
| 25 | +| Compile pipeline | The user's own `@lynx-js/rspeedy` | The actual `.web.bundle` artifact: `pluginReactLynx`, JSX/TSX loader, web env, all of it | |
| 26 | +| Runtime | `@lynx-js/web-core` + `@lynx-js/web-elements` | The `<lynx-view>` custom element, WASM mainthread, decode worker | |
| 27 | + |
| 28 | +We pick `@storybook/web-components` as the renderer (not the React renderer) |
| 29 | +because the thing on screen is a custom element. The React tree lives |
| 30 | +*inside* the Lynx bundle, not in Storybook's React tree. |
| 31 | + |
| 32 | +## Module-resolution boundary: user's rspeedy vs ours |
| 33 | + |
| 34 | +The `@lynx-js/rspeedy` instance we drive **must** be the same module |
| 35 | +instance that the user's `pluginReactLynx` was bound to. pnpm can install |
| 36 | +multiple rspeedy variants under different peer-dep contexts; importing the |
| 37 | +framework's own copy hands us a different instance and the entire React |
| 38 | +Lynx loader chain silently no-ops (manifests as JSX parse errors at compile |
| 39 | +time). |
| 40 | + |
| 41 | +`importUserRspeedy` in `src/preset.ts` resolves rspeedy from the user's |
| 42 | +project root via `import.meta.resolve(specifier, parentURL)`. We rely on the |
| 43 | +two-arg form being stable, which is why `engines.node >= 20.6` — and we |
| 44 | +cannot fall back to `createRequire().resolve` because rspeedy's |
| 45 | +`exports."."` only declares an `import` condition (`createRequire` would |
| 46 | +throw `ERR_PACKAGE_PATH_NOT_EXPORTED`). |
| 47 | + |
| 48 | +This boundary is the reason `loadUserRspeedyConfig` deliberately does |
| 49 | +*nothing* to the user's config — we trust their plugins, their environment |
| 50 | +list, their `output.distPath`, etc. |
| 51 | + |
| 52 | +## Sync vs async preview boundary |
| 53 | + |
| 54 | +`@lynx-js/web-core` uses top-level await (WASM init). Any module that |
| 55 | +imports it transitively becomes an async module under rspack. Storybook's |
| 56 | +`composeConfigs` reads `render` / `renderToCanvas` synchronously off the |
| 57 | +preview module's exports — for an async module, that returns a Promise, the |
| 58 | +fields read as `undefined`, and our framework's render silently loses to |
| 59 | +the renderer default (the *"component annotation is missing"* error that |
| 60 | +new contributors will hit if they ignore this rule). |
| 61 | + |
| 62 | +The package therefore ships **two preview entries** with strictly separated |
| 63 | +roles. Treat the boundary as load-bearing: |
| 64 | + |
| 65 | +- `preview.ts` → `./preview` — **sync only**. Exports `render` and |
| 66 | + `renderToCanvas`. Must never gain a side-effect import that pulls TLA in, |
| 67 | + even transitively. |
| 68 | +- `preview-runtime.ts` → `./preview-runtime` — async, side-effects only, |
| 69 | + exports nothing. This is where `@lynx-js/web-core` and |
| 70 | + `@lynx-js/web-elements/all` are imported and where the web-elements CSS |
| 71 | + is injected. |
| 72 | +- `preset.ts`'s `previewAnnotations` registers `preview-runtime` **before** |
| 73 | + `preview` so the runtime side effects still run while the sync exports |
| 74 | + win `getSingletonField`'s last-pop semantics. |
| 75 | +- `build-config.ts` marks `preview-runtime` as `dts: false` because it has |
| 76 | + no type surface. |
| 77 | + |
| 78 | +If a future change needs more preview-time exports, add them to `preview.ts`. |
| 79 | +If it needs more runtime side effects, add them to `preview-runtime.ts`. |
| 80 | +Never merge the two. |
| 81 | + |
| 82 | +## Two modes of `rsbuildFinal` |
| 83 | + |
| 84 | +A `lynx.config.*` is **mandatory**. If `requireLynxConfig` can't resolve |
| 85 | +one, `rsbuildFinal` throws a hard error telling the user to add one. We |
| 86 | +looked at all 45 `@lynx-js/react` examples in lynx-family/lynx-examples |
| 87 | +and every single one ships a `lynx.config.*`, so a "no-config fallback" |
| 88 | +served zero real-world users and the hidden dependency on us keeping our |
| 89 | +filename list aligned with rspeedy's `CONFIG_FILES` was a silent-breakage |
| 90 | +trap. (Don't resurrect the `staticDirs` fallback — the git history around |
| 91 | +commit that removed it explains why.) |
| 92 | + |
| 93 | +Given a config, `rsbuildFinal` forks on `isDev`: |
| 94 | + |
| 95 | +1. **Dev** — in-process rspeedy dev server, fronted by an rsbuild proxy. |
| 96 | + The framework caches a single rspeedy instance keyed off a global |
| 97 | + symbol (concurrent presets share it). CSS rebuilds are detected via |
| 98 | + `hasCssChange(stats)` and broadcast over an SSE channel; JS rebuilds |
| 99 | + intentionally do *not* broadcast, because rsbuild's standard HMR |
| 100 | + client inside the web bundle handles them with state preservation |
| 101 | + (broadcasting would force-reload `<lynx-view>` and clobber that |
| 102 | + state). |
| 103 | + |
| 104 | +2. **Build** — in-process rspeedy build, then `output.copy` into |
| 105 | + Storybook's output. The source directory is taken from |
| 106 | + `rspeedy.context.distPath`, **not** hardcoded as `<projectRoot>/dist`, |
| 107 | + so a user-customized `output.distPath.root` is honored. |
| 108 | + |
| 109 | +`findLynxConfig`'s filename list must stay aligned with rspeedy's |
| 110 | +`CONFIG_FILES` in `@lynx-js/rspeedy` core; if they diverge, a user with |
| 111 | +e.g. `lynx.config.mts` will get the "no config found" error even though |
| 112 | +rspeedy itself would have picked it up. |
| 113 | + |
| 114 | +## Asset hosting boundary |
| 115 | + |
| 116 | +`.web.bundle` is a binary blob whose CSS `url(...)` and image references |
| 117 | +are stored as **relative strings** like `static/image/foo.png`. web-core |
| 118 | +hands those strings to the shadow DOM unchanged — it does **not** rebase |
| 119 | +them against the bundle's fetch URL. The browser then resolves them against |
| 120 | +the document (`iframe.html`), so they end up as root-absolute |
| 121 | +`/static/image/foo.png` regardless of where the `.web.bundle` itself lives. |
| 122 | + |
| 123 | +Two consequences for our hosting layout: |
| 124 | + |
| 125 | +- `.web.bundle` files are namespaced under `lynx-bundles/` (the user's |
| 126 | + `parameters.lynx.url` points there). We control this prefix. |
| 127 | +- `static/**` assets must land at the **output root**, not under |
| 128 | + `lynx-bundles/`. In dev we proxy `/static/{image,font,svg}` to rspeedy; |
| 129 | + in build mode `output.copy` writes them to root. The whitelist is scoped |
| 130 | + to lynx's standard subpaths so it doesn't intercept Storybook's own |
| 131 | + `static/{js,css,wasm}`. |
| 132 | + |
| 133 | +`output.assetPrefix` is **not** a workaround. `LynxTemplatePlugin` runs |
| 134 | +`new URL(debugInfoPath, publicPath)` whenever publicPath is a custom string |
| 135 | +≠ `'auto'` / `'/'`, and a relative-absolute prefix throws `Invalid URL` at |
| 136 | +build time. publicPath has to stay default; the asset layout has to match. |
| 137 | + |
| 138 | +## Where the load-bearing hacks live |
| 139 | + |
| 140 | +The non-obvious workarounds are documented inline at the point of use — |
| 141 | +this is just a map so you don't have to grep for them. |
| 142 | + |
| 143 | +- `src/preview.ts` — `createLynxView` element-creation order, the SSE-driven |
| 144 | + CSS reload bridge, the `process.env.NODE_ENV` DCE guard, and the |
| 145 | + `renderToCanvas` reuse path that avoids tearing down the Lynx runtime on |
| 146 | + arg changes. Each has a comment block explaining what breaks if you touch |
| 147 | + it. Read those before editing. |
| 148 | +- `src/preset.ts` — `importUserRspeedy` (the `import.meta.resolve` rationale), |
| 149 | + the `callerName` omission in `setupRspeedyDev` (passing it disables |
| 150 | + `pluginReactLynx`'s loader chain), the CSS-only filter in |
| 151 | + `onDevCompileDone`, and `core.builder.options.lazyCompilation = false` |
| 152 | + (lazy compilation hides the wasm-bindgen wasm import in |
| 153 | + `@lynx-js/web-mainthread-apis` behind a lazy-compilation-proxy, so rspack |
| 154 | + never propagates `instantiateWasm` into the worker runtime and |
| 155 | + `__webpack_require__.v` is missing at runtime — production builds work, |
| 156 | + dev crashes with "TypeError: __webpack_require__.v is not a function"). |
| 157 | + |
| 158 | +If you find yourself "fixing" something in those areas without first |
| 159 | +reading the comment, stop — the comment is the spec. |
| 160 | + |
| 161 | +## External references |
| 162 | + |
| 163 | +- **lynx-stack** (upstream) — https://github.com/lynx-family/lynx-stack. |
| 164 | + When source comments cite a file by repo-relative path (e.g. |
| 165 | + `packages/web-platform/web-core/ts/client/mainthread/LynxView.ts`), they |
| 166 | + mean within that repo. Don't assume any local checkout. |
| 167 | +- **rspeedy public API surface** — `packages/rspeedy/core/etc/rspeedy.api.md` |
| 168 | + inside lynx-stack. This is the API Extractor report; consult it before |
| 169 | + guessing types or option shapes for anything passed to `createRspeedy` |
| 170 | + or returned by `loadConfig`. |
| 171 | +- **Documentation site** — https://storybook.rsbuild.rs (built from |
| 172 | + `website/` at the repo root). |
| 173 | + |
| 174 | +## Package-specific commands |
| 175 | + |
| 176 | +Things that differ from the repo-root `AGENTS.md`: |
| 177 | + |
| 178 | +```bash |
| 179 | +# Rebuild this package only — bundler entries come from build-config.ts |
| 180 | +pnpm --filter storybook-react-lynx-web-rsbuild run prep |
| 181 | + |
| 182 | +# Drive the integration sandbox (http://localhost:6010) |
| 183 | +pnpm --filter @sandboxes/react-lynx-web storybook |
| 184 | +pnpm --filter @sandboxes/react-lynx-web build:storybook |
| 185 | +``` |
| 186 | + |
| 187 | +When you add or remove an entry under `src/`, **three** files have to move |
| 188 | +together: `build-config.ts` (what the bundler emits), `package.json` |
| 189 | +`exports` (what Node's resolver will accept), and `package.json` |
| 190 | +`bundler.entries`. Forgetting any of them produces silent |
| 191 | +"file not in dist" or "subpath not exported" failures downstream. |
| 192 | + |
| 193 | +## Testing / validation |
| 194 | + |
| 195 | +There is no Rstest coverage in this package. Integration is gated through |
| 196 | +`sandboxes/react-lynx-web`: |
| 197 | + |
| 198 | +- After editing anything in `src/`, run `pnpm --filter |
| 199 | + storybook-react-lynx-web-rsbuild run prep` first — the framework ships as |
| 200 | + compiled `dist/**` and the sandbox imports from there. |
| 201 | +- Then exercise both `pnpm storybook` (dev) and `pnpm build:storybook` |
| 202 | + (build) in the sandbox. |
| 203 | +- Smoke check that the sync preview boundary still holds: in the iframe, |
| 204 | + `window.__STORYBOOK_PREVIEW__.storyStore.projectAnnotations.render` |
| 205 | + stringified should reference `parameters?.lynx?.url`. If you see the |
| 206 | + web-components default, something pulled TLA into `preview.ts` and the |
| 207 | + exports are now a Promise. |
0 commit comments