Skip to content

Commit 2bb8637

Browse files
committed
better sandbox style
1 parent 1f3b650 commit 2bb8637

File tree

8 files changed

+712
-84
lines changed

8 files changed

+712
-84
lines changed
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
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.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AGENTS.md
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# storybook-react-lynx-web-rsbuild
2+
3+
Storybook for ReactLynx Web and Rsbuild: Develop ReactLynx components in isolation with Hot Reloading.
4+
5+
> **Experimental.** This framework wraps your existing `@lynx-js/rspeedy`
6+
> pipeline and renders each story through the upstream `<lynx-view>`
7+
> custom element. See [Limitations](#limitations) before adopting.
8+
9+
## Installation
10+
11+
```bash
12+
npm install storybook-react-lynx-web-rsbuild \
13+
@lynx-js/react @lynx-js/rspeedy @lynx-js/react-rsbuild-plugin \
14+
@lynx-js/web-core @lynx-js/web-elements
15+
```
16+
17+
You also need a `lynx.config.ts` in your project root that invokes
18+
`pluginReactLynx()` and lists every component you want to render as a
19+
`source.entry` — see [Usage](#usage).
20+
21+
## Usage
22+
23+
In your `.storybook/main.ts`:
24+
25+
```ts
26+
import type { StorybookConfig } from 'storybook-react-lynx-web-rsbuild'
27+
28+
const config: StorybookConfig = {
29+
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
30+
addons: ['@storybook/addon-docs'],
31+
framework: {
32+
name: 'storybook-react-lynx-web-rsbuild',
33+
options: {},
34+
},
35+
}
36+
37+
export default config
38+
```
39+
40+
In your project root `lynx.config.ts`:
41+
42+
```ts
43+
import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'
44+
import { defineConfig } from '@lynx-js/rspeedy'
45+
46+
export default defineConfig({
47+
plugins: [pluginReactLynx()],
48+
environments: {
49+
web: {},
50+
lynx: {},
51+
},
52+
source: {
53+
entry: {
54+
// One entry per component you want to render in Storybook.
55+
Button: './src/components/button-entry.tsx',
56+
},
57+
},
58+
})
59+
```
60+
61+
Each entry file simply mounts your component:
62+
63+
```tsx
64+
// src/components/button-entry.tsx
65+
import { root } from '@lynx-js/react'
66+
67+
import { Button } from './Button.tsx'
68+
import './Button.css'
69+
70+
root.render(<Button />)
71+
```
72+
73+
Then write a story that points at the built `.web.bundle`:
74+
75+
```ts
76+
// src/components/Button.stories.ts
77+
import type { Meta, StoryObj } from 'storybook-react-lynx-web-rsbuild'
78+
79+
const meta = {
80+
title: 'Example/Button',
81+
parameters: {
82+
lynx: {
83+
url: '/lynx-bundles/Button.web.bundle',
84+
},
85+
},
86+
argTypes: {
87+
label: { control: 'text' },
88+
primary: { control: 'boolean' },
89+
},
90+
} satisfies Meta
91+
92+
export default meta
93+
type Story = StoryObj
94+
95+
export const Primary: Story = {
96+
args: { primary: true, label: 'Button' },
97+
}
98+
```
99+
100+
Read Storybook args inside your component via `useGlobalProps()` from
101+
`@lynx-js/react`. Augment the `GlobalProps` interface for type safety:
102+
103+
```tsx
104+
import { useGlobalProps } from '@lynx-js/react'
105+
106+
declare module '@lynx-js/react' {
107+
interface GlobalProps {
108+
label?: string
109+
primary?: boolean
110+
}
111+
}
112+
113+
export function Button() {
114+
const { label = 'Button', primary = false } = useGlobalProps()
115+
// ...
116+
}
117+
```
118+
119+
## Framework Options
120+
121+
### `lynxConfigPath`
122+
123+
Path to your rspeedy/lynx config file, relative to the project root.
124+
Defaults to `lynx.config.ts` (also tries `.js`, `.mts`, `.mjs`).
125+
126+
```ts
127+
framework: {
128+
name: 'storybook-react-lynx-web-rsbuild',
129+
options: {
130+
lynxConfigPath: 'config/lynx.config.ts',
131+
},
132+
}
133+
```
134+
135+
### `lynxBundlePrefix`
136+
137+
URL prefix under which compiled `.web.bundle` files are served. Defaults
138+
to `/lynx-bundles`. Your story's `parameters.lynx.url` must start with
139+
this prefix.
140+
141+
## Features
142+
143+
- Runs your own `@lynx-js/rspeedy` pipeline in-process (no sidecar dev
144+
server to manage)
145+
- JS HMR with Fast Refresh state preservation for background-thread edits
146+
- CSS hot reload via SSE (edits to `.css/.scss/.less/...` refresh the
147+
`<lynx-view>` template without dropping the bundle cache)
148+
- Storybook controls update via `updateGlobalProps()` without remounting
149+
— your component state survives arg changes
150+
- Production build via `storybook build` that runs `rspeedy build`
151+
in-process and copies `.web.bundle` + static assets into the Storybook
152+
output
153+
154+
## Limitations
155+
156+
- **No Docs mode inline rendering.** ReactLynx components only run inside
157+
a `<lynx-view>` with WASM + Worker; docs pages that embed stories will
158+
render the empty custom element host.
159+
- **No `play()` interaction tests yet.** The component lives inside a
160+
shadow root and a cross-thread worker; upstream test harnesses need
161+
more work.
162+
- **No automatic args docgen.** `react-docgen` does not understand the
163+
ReactLynx transforms.
164+
- **Each story spawns a Worker.** Lynx runs background-thread JS in a
165+
dedicated Web Worker; switching between many stories in one session
166+
will accumulate memory pressure.
167+
- **Browser requirements match `@lynx-js/web-core`:** Chrome ≥ 92,
168+
Safari ≥ 16.4.
169+
170+
## 🤖 Agent Skills
171+
172+
Using an AI coding agent? Install the agent skills for guided setup:
173+
`npx skills add rstackjs/agent-skills --skill storybook-rsbuild`
174+
175+
## License
176+
177+
MIT

0 commit comments

Comments
 (0)