Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/native-mpa-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@lynx-js/go-web': minor
---

Add opt-in preview extension points for native modules and multi-page (MPA) examples.

- **Level A** — `GoConfig.previewNativeEnv` and the per-instance `nativeEnv` prop forward a generic native environment (`onNativeModulesCall`, `nativeModulesMap`, `napiModulesMap`, `onNapiModulesCall`, and a static-or-factory `globalProps` / `initData`) to the previewed `<lynx-view>` before it starts.
- **Level B** — `GoConfig.PreviewRuntime` replaces the built-in single-card renderer with a custom component (receiving every previewable entry plus the resolved native environment) so embedders can stack cards and route cross-page navigation, while go-web keeps owning the tab bar, QR, code browser, scaling, and SSG path.

Both are backwards compatible and product-agnostic: when unset, the preview is identical to before, and go-web hard-codes no framework module names or URL scheme.
19 changes: 19 additions & 0 deletions .github/workflows/workflow-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,30 @@ jobs:
- name: Type Check
run: pnpm typecheck

test:
name: Unit Test
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout Repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0

- name: Setup
uses: ./.github/actions/ci-setup

- name: Test
run: pnpm test

build-example:
name: Build Example App
needs:
- format-check
- typecheck
- test
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
Expand Down
125 changes: 124 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,98 @@ Transition behavior:
- `fit → fit` on container resize: smooth `transform` transition
- `fit ↔ responsive` mode switch: hard cut, no transition

### Native modules & multi-page (MPA) previews

By default the web preview renders **one** `<lynx-view>` with no native bridge.
Two opt-in extension points let embedders preview examples that call **native
modules** and that navigate across **multiple Lynx pages**. Both are generic —
go-web has no knowledge of any framework's module names or URL scheme — and both
are backwards compatible: when unset, the preview is byte-for-byte identical to
today.

#### Level A — native environment (`previewNativeEnv` / `nativeEnv`)

Forward a native environment to the previewed `<lynx-view>` so a bundle that
calls a native module renders instead of failing with
`Native module ... is not registered`. Set it site-wide on `GoConfig`
(`previewNativeEnv`) and/or per instance on `<Go>` (`nativeEnv`, shallow-merged
over the config — per-instance keys win).

| Field | Type | Description |
| --------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `onNativeModulesCall` | `(name, data, moduleName) => any` | Handler for NativeModule calls made by the bundle. web-core caches calls made before assignment (safe). |
| `nativeModulesMap` | `Record<string, string>` | Native-modules definition: `module-name → ESM url`. Consumed by the worker at init, applied before start. |
| `napiModulesMap` | `Record<string, string>` | Napi-modules definition (advanced). |
| `onNapiModulesCall` | `(...) => any` | Handler for NapiModule calls (advanced). |
| `globalProps` | `Cloneable \| (entryName) => Cloneable` | Per-card `globalProps` (e.g. a container id / query params). Read when the view starts. |
| `initData` | `Cloneable \| (entryName) => Cloneable` | Per-card `initData`. Read when the view starts. |

```tsx
import type { GoConfig } from '@lynx-js/go-web';

const config: GoConfig = {
exampleBasePath: '/lynx-examples',
previewNativeEnv: {
onNativeModulesCall: (name, data, moduleName) => {
// Deliver the call to your host bridge and return the result.
return myBridge.call(moduleName, name, data);
},
nativeModulesMap: { MyModule: 'https://cdn.example.com/my-module.js' },
globalProps: (entryName) => ({ containerId: `preview:${entryName}` }),
},
};
```

**Ordering.** `nativeModulesMap` / `napiModulesMap` / `globalProps` / `initData`
are read by web-core when the view starts; go-web assigns them from the element
`ref` (the same path the existing `browserConfig` init uses), which lands before
web-core's async start reads them. `onNativeModulesCall` may be assigned late
because web-core caches pre-assignment calls.

#### Level B — pluggable preview runtime (`PreviewRuntime`)

The built-in renderer shows a single card, so cross-page navigation has nowhere
to go. Set `GoConfig.PreviewRuntime` to **replace just the inner card renderer**
— go-web keeps owning the tab bar, QR, code browser, fit/scaling, and SSG path.
The component receives every previewable entry plus the resolved Level-A
environment:

```tsx
import type { PreviewRuntimeProps } from '@lynx-js/go-web';

function MyRuntime(props: PreviewRuntimeProps) {
// props.entries — every entry with a web bundle ({ name, webUrl, file })
// props.activeEntry — current entry name
// props.src — active entry's web bundle URL
// props.nativeEnv — resolved Level-A environment
// props.designWidth / designHeight / fit / webPreviewMode — scaling params
// → stack <lynx-view> cards and route navigation between them.
}

const config: GoConfig = {
exampleBasePath: '/lynx-examples',
PreviewRuntime: MyRuntime,
};
```

**Two shapes, one hook.** This single slot supports both MPA designs:

- **B1 — in-process card stack (recommended).** Keep a stack of `<lynx-view>`
cards in React state; push on navigate, pop on `back`. Lower cards stay
mounted (stable React key) so their heap and state survive the round-trip. No
cross-origin handshake, type-safe composition, reuses go-web scaling. See the
runnable prototype in [`example/src/mpa/`](./example/src/mpa/)
(`StackedPreviewRuntime.tsx` + the framework-agnostic `card-stack.ts`).
- **B2 — iframe runtime.** Render a real `<iframe src={runtimeUrl}>` from your
`PreviewRuntime` and pass the entries via query/`postMessage`. Because an
iframe is a real nested browsing context, `window.history` and navigation are
naturally scoped to it — ideal for an embedder that already has a full-page
"web shell". It is simply one implementation of the same `PreviewRuntime` hook.

go-web recommends B1 as the default and treats B2 as an escape hatch. Try both
live in the example app via the **Preview** control (`Default` / `Native` /
`MPA`).

## Development

```bash
Expand All @@ -182,11 +274,42 @@ pnpm prepare:clean

CI always runs `prepare:clean` to ensure that examples are up to date.

### Testing

The preview extension points are verified in two layers:

```bash
pnpm test # Vitest — Node unit tests (runs in CI)
pnpm test:browser # Playwright — real web-core integration (opt-in, local)
```

- **Unit (`pnpm test`)** — pure logic plus the native-env wiring against a
faithful `<lynx-view>` fake: apply-before-start ordering, merge/resolve,
native-call delivery (including calls cached before the handler is assigned),
and the MPA card-stack navigation (open → back with the root intact). These
validate go-web's own code and are the CI gate.
- **Real-browser (`pnpm test:browser`)** — drives the built example app in
headless Chromium against the **real** `@lynx-js/web-core` runtime and asserts:
the default preview still boots exactly one `<lynx-view>`; Level A actually
reaches the real element (`nativeModulesMap` / `onNativeModulesCall` /
`globalProps`) and it boots; and Level B pushes a **second** real `<lynx-view>`
on a native `open` call, with `Back` returning to the original root element
(same DOM node ⇒ heap and state intact). Prereq: `cd example && pnpm build`.
It is intentionally out of CI (needs the example built and a Chromium binary).

> Literal acceptance of a native-calling / multi-page example needs a fixture
> bundle that actually invokes native modules (none exists in the public gallery
> — the `vue-router` example uses in-bundle `createMemoryHistory`). `pnpm
test:browser` is the harness such a fixture (or the downstream MPA bundle)
> slots into.

## CI

All three checks must pass on every PR:
All checks must pass on every PR:

- **Format Check** — `pnpm format:check`
- **Type Check** — `pnpm typecheck` at the repo root
- **Unit Test** — `pnpm test` (Vitest)
- **Build Example App** — standalone Rsbuild example
- **Build Rspress Example** — rspress integration example

Expand Down
50 changes: 50 additions & 0 deletions example/src/demo-native-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Demo `PreviewNativeEnv` (Level A) for the example app.
*
* Shows the three generic hooks go-web forwards to the previewed `<lynx-view>`:
* 1. `onNativeModulesCall` — receives native-module calls made by the bundle.
* 2. `nativeModulesMap` — `module-name -> ESM url` definition consumed by
* the worker at init (here an inline `data:` module, purely illustrative;
* it stays dormant unless a bundle actually imports that module).
* 3. `globalProps` — a `(entryName) => Cloneable` factory injecting a
* per-card container id / query params.
*
* These are opt-in: examples that don't use native modules are unaffected.
*/
import type { PreviewNativeEnv } from '../../src/index';

/** An inline ESM native module, referenced by `nativeModulesMap` below. */
const DEMO_MODULE_URL =
'data:text/javascript,' +
encodeURIComponent(
`export default function (NativeModules, NativeModulesCall) {
return {
ping: (msg) => NativeModulesCall('ping', msg, 'DemoModule'),
};
}`,
);

export const demoNativeEnv: PreviewNativeEnv = {
// 1. Handler — every native-module call the bundle makes lands here.
onNativeModulesCall: (name, data, moduleName) => {
console.log('[demo native call]', { moduleName, name, data });
if (name === 'ping') {
return { ok: true, echo: data, at: 'go-web-demo' };
}
// Returning undefined leaves other modules to their defaults.
return undefined;
},

// 2. Native-modules definition passthrough (module-name -> ESM url).
nativeModulesMap: {
DemoModule: DEMO_MODULE_URL,
},

// 3. Per-card globalProps factory — inject a container id derived from entry.
// `Cloneable` values are flat primitives (web-core structured-clone shape).
globalProps: (entryName) => ({
containerId: `go-web-demo:${entryName}`,
source: 'go-web',
preview: true,
}),
};
27 changes: 25 additions & 2 deletions example/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@ import { createRoot } from 'react-dom/client';
import type { BundledLanguage, ShikiTransformer } from 'shiki';
import type { GoConfig, PreviewTab } from '../../src/config';
import { Go, GoConfigProvider } from '../../src/index';
import { demoNativeEnv } from './demo-native-env';
import { StackedPreviewRuntime } from './mpa/StackedPreviewRuntime';
import './styles.css';

// Opt-in preview extensions demo (Level A / Level B). 'default' keeps the
// built-in single-card preview with no native env — identical to before.
type PreviewExt = 'default' | 'native' | 'mpa';

const LOGO_LIGHT =
'https://lf-lynx.tiktok-cdns.com/obj/lynx-artifacts-oss-sg/lynx-website/assets/lynx-dark-logo.svg';
const LOGO_DARK =
Expand Down Expand Up @@ -530,6 +536,7 @@ function App() {
const [mode, setMode] = useState<'linked' | 'preview' | 'source'>(
initial.mode ?? 'linked',
);
const [previewExt, setPreviewExt] = useState<PreviewExt>('default');
const [copied, setCopied] = useState(false);
const [exampleSearch, setExampleSearch] = useState('');
const [entrySearch, setEntrySearch] = useState('');
Expand Down Expand Up @@ -749,6 +756,10 @@ function App() {
useLang: () => lang,
useDark: () => dark,
CodeBlock: StandaloneCodeBlock,
// Level A: forward a demo native env to the previewed <lynx-view>.
...(previewExt !== 'default' ? { previewNativeEnv: demoNativeEnv } : {}),
// Level B: replace the single-card renderer with a stacked MPA runtime.
...(previewExt === 'mpa' ? { PreviewRuntime: StackedPreviewRuntime } : {}),
};

return (
Expand Down Expand Up @@ -847,6 +858,18 @@ function App() {
/>
</ControlGroup>

<ControlGroup label="Preview">
<AdaptiveControl
value={previewExt}
options={[
{ value: 'default', label: 'Default' },
{ value: 'native', label: 'Native' },
{ value: 'mpa', label: 'MPA' },
]}
onChange={(v) => setPreviewExt(v as PreviewExt)}
/>
</ControlGroup>

{/* JSX button */}
<button
className="toolbar-btn"
Expand Down Expand Up @@ -1254,7 +1277,7 @@ function App() {
{/* Desktop */}
<div style={{ flex: '1 1 500px', minWidth: 0 }}>
<Go
key={`desktop-${example}-${selectedEntry}-${defaultTab}-${mode}`}
key={`desktop-${example}-${selectedEntry}-${defaultTab}-${mode}-${previewExt}`}
example={example}
defaultFile={defaultFile}
defaultTab={defaultTab}
Expand Down Expand Up @@ -1288,7 +1311,7 @@ function App() {
}}
>
<Go
key={`mobile-${example}-${selectedEntry}-${defaultTab}-${mode}`}
key={`mobile-${example}-${selectedEntry}-${defaultTab}-${mode}-${previewExt}`}
example={example}
defaultFile={defaultFile}
defaultTab={defaultTab}
Expand Down
Loading