feat(preview): native-module & multi-page (MPA) preview extension points - #62
feat(preview): native-module & multi-page (MPA) preview extension points#62Huxpro wants to merge 2 commits into
Conversation
The web preview rendered one `<lynx-view>` with no way to register a native-modules bridge, inject per-card globalProps, or stack cards, so examples that call native modules failed and multi-page (MPA) examples could not be demonstrated. Add two opt-in, product-agnostic hooks. Level A — native environment. `GoConfig.previewNativeEnv` and the per-instance `nativeEnv` prop forward a generic environment (`onNativeModulesCall`, `nativeModulesMap`, `napiModulesMap`, `onNapiModulesCall`, and static-or-factory `globalProps`/`initData`) to the previewed view. Values web-core reads at worker init are assigned from the element ref (the same path `browserConfig` already uses), which lands before web-core's async start reads them. Level B — pluggable preview runtime. `GoConfig.PreviewRuntime` replaces just the inner card renderer (go-web keeps owning tabs/QR/code-browser/ scaling/SSG), receiving every previewable entry plus the resolved native environment. One hook covers both MPA shapes: an in-process card stack (recommended) or an embedder iframe runtime. A runnable B1 prototype lives in example/src/mpa/ with a demo native env, wired behind an opt-in Preview toggle. Both are backwards compatible: when unset, the preview is identical to before, and go-web hard-codes no framework module names or URL scheme. Adds Vitest with tests for the native-env wiring and the card-stack navigation, plus a CI test job and README docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnnSHyr2e9qXzWHZSCSMVx
🦋 Changeset detectedLatest commit: 67dce58 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
✅ Deploy Preview for lynx-go-web ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
There was a problem hiding this comment.
Pull request overview
Adds opt-in extension points to the go-web live preview so embedders can (A) provide a native-module environment to <lynx-view> and (B) swap the single-card web preview renderer for a pluggable runtime that can support multi-page (MPA) flows (e.g., stacked cards). This is paired with a demo implementation and new unit tests to validate the core logic paths.
Changes:
- Introduces Level A native-env plumbing (
previewNativeEnv+ per-instancenativeEnv) and helper utilities to apply/merge/resolve cloneable inputs. - Introduces Level B
PreviewRuntimeslot and corresponding entry/prop types to enable custom (e.g., stacked) preview renderers. - Adds Vitest + unit tests, updates CI to run unit tests, and adds an example MPA runtime + demo native env.
Reviewed changes
Copilot reviewed 19 out of 20 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| vitest.config.ts | Adds Vitest configuration for pure-logic node-environment tests. |
| test/preview-native-env.test.ts | Unit tests for resolving/merging/applying the preview native environment. |
| test/mpa-card-stack.test.ts | Unit tests for card stack reducer and demo nav-intent mapping. |
| src/index.ts | Exports new public API types/helpers for Level A and Level B. |
| src/example-preview/preview-runtime.ts | Defines the Level B PreviewRuntime component contract and entry model. |
| src/example-preview/preview-native-env.ts | Implements Level A types and helper functions (resolveCloneableInput, merge/apply). |
| src/example-preview/index.tsx | Wires config + per-instance native env merging and builds entries for custom runtimes. |
| src/example-preview/components/web-iframe.tsx | Applies Level A native env to <lynx-view> from the ref before worker init. |
| src/example-preview/components/index.tsx | Switches between built-in WebIframe and custom PreviewRuntime rendering. |
| src/config.tsx | Adds GoConfig.previewNativeEnv and GoConfig.PreviewRuntime extension points. |
| README.md | Documents Level A/Level B usage and updates CI section to include unit tests. |
| package.json | Adds pnpm test script and Vitest dev dependency. |
| pnpm-lock.yaml | Locks Vitest + transitive dependencies. |
| example/src/mpa/StackedPreviewRuntime.tsx | Demo B1 stacked-card preview runtime implementation. |
| example/src/mpa/nav-intent.ts | Demo-only mapping from native calls to navigation intents. |
| example/src/mpa/card-stack.ts | Pure reducer/state model for the stacked-card runtime. |
| example/src/main.tsx | Adds example-app toggle to demo Default/Native/MPA preview modes. |
| example/src/demo-native-env.ts | Demo Level A native env (nativeModulesMap, handler, globalProps factory). |
| .github/workflows/workflow-test.yml | Adds a Unit Test job and gates builds on it. |
| .changeset/native-mpa-preview.md | Changeset entry for the new minor feature/API additions. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** | ||
| * Imperatively apply a resolved native env to a `<lynx-view>` element for a | ||
| * given entry. Idempotent per element+env via the provided `applied` guard set. | ||
| * |
| const handleRef = useCallback( | ||
| (el: LynxViewElement | null) => { | ||
| if (!el) return; | ||
| // Apply the resolved native env before the view starts (see | ||
| // applyPreviewNativeEnv for the ordering guarantee), tagging each card | ||
| // with its own id via globalProps so stacked containers stay distinct. | ||
| applyPreviewNativeEnv(el, nativeEnv, card.entryName, appliedRef.current); | ||
| }, | ||
| [card.entryName, nativeEnv], | ||
| ); |
The Vitest suite validates go-web's wiring against a faithful <lynx-view> fake. Add an opt-in Playwright harness (`pnpm test:browser`) that drives the built example app in headless Chromium against the REAL @lynx-js/web-core runtime and asserts: - default preview still boots exactly one <lynx-view> (no regression); - Level A reaches the real element (nativeModulesMap / onNativeModulesCall / globalProps) and it boots; - Level B pushes a second real <lynx-view> on a native `open` call and Back returns to the original root element (same DOM node => state intact). Kept out of CI (needs the example built + a Chromium binary). Documents the E2E gap: literal acceptance of a native-calling example needs a fixture bundle, which this harness slots into. README gains a Testing section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnnSHyr2e9qXzWHZSCSMVx
|
|
||
| /** | ||
| * Imperatively apply a resolved native env to a `<lynx-view>` element for a | ||
| * given entry. Idempotent per element+env via the provided `applied` guard set. |
| if (!env) return; | ||
| if (applied.has(el)) return; | ||
| applied.add(el); | ||
|
|
||
| // Worker-init values first, before any late handler assignment. | ||
| if (env.nativeModulesMap !== undefined) { | ||
| el.nativeModulesMap = env.nativeModulesMap; | ||
| } | ||
| if (env.napiModulesMap !== undefined) { | ||
| el.napiModulesMap = env.napiModulesMap; | ||
| } | ||
| const globalProps = resolveCloneableInput(env.globalProps, entryName); | ||
| if (globalProps !== undefined) { | ||
| el.globalProps = globalProps; | ||
| } | ||
| const initData = resolveCloneableInput(env.initData, entryName); | ||
| if (initData !== undefined) { | ||
| el.initData = initData; | ||
| } | ||
| if (env.onNativeModulesCall !== undefined) { | ||
| el.onNativeModulesCall = env.onNativeModulesCall; | ||
| } | ||
| if (env.onNapiModulesCall !== undefined) { | ||
| el.onNapiModulesCall = env.onNapiModulesCall; | ||
| } |
| const handleLynxViewRef = useCallback((el: LynxView | null) => { | ||
| setLynxView(el); | ||
| if (!el) return; | ||
| // Apply Level-A native env before browserConfig. Both are consumed by | ||
| // web-core inside its async `#render()` microtask (after the iframe realm |
| // Apply the resolved native env before the view starts (see | ||
| // applyPreviewNativeEnv for the ordering guarantee), tagging each card | ||
| // with its own id via globalProps so stacked containers stay distinct. |
Summary
The web preview renders a single
<lynx-view>(components/web-iframe.tsx) with no way to register a native-modules bridge, inject per-cardglobalProps, or stack cards. So any example whose bundle calls a native module fails at runtime (Native module … is not registered), and multi-page (MPA) examples can't be demonstrated — cross-page navigation has no second card to go to.This PR adds two opt-in, product-agnostic extension points. go-web hard-codes no framework module names or URL scheme; it only provides generic hooks. When unset, the preview is byte-for-byte identical to today.
web-core API confirmed (pinned
@lynx-js/web-core@0.20.3)<lynx-view>(LynxViewElement) exposes:onNativeModulesCall(setter; runtime caches calls made before assignment),nativeModulesMap(module-name → ESM url, read at worker init),napiModulesMap/onNapiModulesCall, andglobalProps/initData. Crucially,#render()readsnativeModulesMap/globalProps/initDataasynchronously inside aqueueMicrotaskafterawait-ing the iframe realm — so assigning them from the elementref(exactly the path the existingbrowserConfiginit already uses) lands before web-core consumes them. Types are derived fromLynxViewElementso they stay correct across web-core versions.Level A — native environment (required)
New
GoConfig.previewNativeEnvand per-instancenativeEnvprop (shallow-merged, per-instance wins) forward a generic env to the previewed view:onNativeModulesCallnativeModulesMapmodule-name → ESM urldefinition (applied before start)napiModulesMap/onNapiModulesCallglobalProps/initDataCloneableor(entryName) => CloneablefactoryLevel B — pluggable preview runtime
New
GoConfig.PreviewRuntimereplaces just the inner card renderer — go-web keeps owning the tab bar, QR, code browser, fit/scaling, and SSG path. The component receives every previewableentry(with absolute web URLs) plus the resolved native env.A/B design tradeoff (evaluated both, recommending B1)
One hook covers both MPA shapes:
<lynx-view>cards in React state; push on navigate, pop onback. Lower cards stay mounted (stable React key) so their heap and state survive the round-trip. No cross-origin/postMessage handshake, type-safe composition, reuses go-web scaling.PreviewRuntimethat renders<iframe src={runtimeUrl}>and passes entries via query/postMessage. An iframe is a real nested browsing context, sowindow.history/navigation are naturally scoped to it — ideal for an embedder that already has a full-page "web shell". B2 is simply one implementation of the samePreviewRuntimehook, so go-web ships a single generic slot rather than two APIs.Recommendation: B1 as the built-in default (simpler, in-process, satisfies the acceptance test via an in-memory stack); B2 documented as the escape hatch for embedders with an existing full-page shell.
Demo + tests
example/src/mpa/— a runnable B1 prototype (StackedPreviewRuntime.tsx+ framework-agnosticcard-stack.ts+ demo-sidenav-intent.ts) anddemo-native-env.ts(Level A:onNativeModulesCall+nativeModulesMap+ aglobalPropsfactory). Wired into the example app behind an opt-in Preview toggle (Default/Native/MPA) — default is unchanged.resolveCloneableInput,mergePreviewNativeEnv, andapplyPreviewNativeEnvagainst a faithful<lynx-view>fake: worker-init values set before start, idempotency, and a native call (incl. one cached before handler assignment) delivered to the embedder's handler.cardStackReducer+resolveDemoNavIntent: opening a second card andbackreturns to the first with its state intact (root card identity preserved).Compatibility & checks
nativeEnvunset ⇒ no-op;PreviewRuntimeunset ⇒ built-inWebIframe). SSG path preserved.pnpm format:check✅ ·pnpm typecheck✅ ·pnpm test✅ (19) · exampletsc --noEmit✅ ·pnpm build✅. Added a Unit Test CI job.Public API additions
GoConfig.previewNativeEnv,GoConfig.PreviewRuntime,GoProps.nativeEnv; exportedPreviewNativeEnv,CloneableInput,Cloneable,NativeModulesCall/Map,NapiModulesCall/Map,PreviewRuntimeComponent/Props/Entry, and helpersresolveCloneableInput/mergePreviewNativeEnv/applyPreviewNativeEnv.🤖 Generated with Claude Code
Generated by Claude Code