}
+
+function makeMiniApp(opts: { tabBar?: TabBarSpec; rootPagePath?: string } = {}) {
+ const rootPagePath = opts.rootPagePath ?? 'pages/home/home'
+ const tabPaths = new Set((opts.tabBar?.list ?? []).map((item) => item.pagePath))
+ const listeners = new Map
void>>()
+ let openCount = 0
+
+ const subscribe = (channel: string, listener: (payload: never) => void): (() => void) => {
+ let bucket = listeners.get(channel)
+ if (!bucket) {
+ bucket = new Set()
+ listeners.set(channel, bucket)
+ }
+ bucket.add(listener)
+ return () => { bucket?.delete(listener) }
+ }
+
+ const miniApp = {
+ appId: 'demo',
+ appSessionId: 's1',
+ pagePath: rootPagePath,
+ query: {},
+ rootWindowConfig: {},
+ resourceBaseUrl: '',
+ apiRegistry: {},
+ getInitialDevice: () => null,
+ getRenderPreloadUrl: () => '',
+ getTabBarConfig: () => opts.tabBar ?? null,
+ getHomePagePath: () => rootPagePath,
+ createRenderHostUrl: () => 'about:blank',
+ openPage: vi.fn((pagePath: string) => Promise.resolve({
+ bridgeId: `bridge_${++openCount}`,
+ pagePath,
+ isTab: tabPaths.has(pagePath),
+ windowConfig: {},
+ })),
+ closePage: vi.fn(),
+ notifyLifecycle: vi.fn(),
+ notifyNavCallback: vi.fn(),
+ notifyApiResponse: vi.fn(),
+ notifyResize: vi.fn(),
+ notifyActivePage: vi.fn(),
+ notifyPageStack: vi.fn(),
+ notifySessionActive: vi.fn(),
+ onSimulatorEvent: subscribe,
+ onSessionEvent: subscribe,
+ }
+
+ return {
+ miniApp,
+ emitNavAction(payload: Omit): void {
+ for (const fn of listeners.get(E.NAV_ACTION) ?? []) {
+ (fn as unknown as (p: NavActionPayload) => void)({
+ appSessionId: 's1',
+ callbacks: {},
+ ...payload,
+ })
+ }
+ },
+ }
+}
+
+function mountShell(h: ReturnType) {
+ return render(
+ ,
+ )
+}
+
+/** Let every queued route (and the IPC round trips it awaits) run to completion. */
+async function settle(): Promise {
+ await act(async () => { await Promise.resolve() })
+ await act(async () => { await Promise.resolve() })
+ await act(async () => { await Promise.resolve() })
+}
+
+/**
+ * Capture the single `OrientationController` instance `useOrientation` constructs, by spying on a prototype method every instance calls during render — the spy still runs the real implementation, it only observes `this`.
+ */
+function captureController(): { get: () => OrientationController } {
+ let captured: OrientationController | undefined
+ const original = OrientationController.prototype.openPage
+ vi.spyOn(OrientationController.prototype, 'openPage').mockImplementation(
+ function (this: OrientationController, ...args: Parameters) {
+ // eslint-disable-next-line @typescript-eslint/no-this-alias -- capturing the constructed instance is the point of this spy
+ captured = this
+ return original.apply(this, args)
+ },
+ )
+ return {
+ get: () => {
+ if (!captured) throw new Error('OrientationController.openPage was never called')
+ return captured
+ },
+ }
+}
+
+function knownCount(ctrl: OrientationController): number {
+ return Array.from(ctrl.knownBridgeIds()).length
+}
+
+describe('OrientationController resource census across route churn', () => {
+ // Each test installs its own prototype spy to capture the instance `useOrientation` constructs; left in place it would wrap the previous test's wrapper instead of the real method on the next `captureController()`.
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('returns to its baseline page count after repeated navigateTo/navigateBack round trips', async () => {
+ const spy = captureController()
+ const h = makeMiniApp()
+ mountShell(h)
+ await settle()
+
+ const ctrl = spy.get()
+ const baseline = knownCount(ctrl)
+ expect(baseline, 'only the root page is mounted before any route runs').toBe(1)
+
+ const ROUNDS = 6
+ for (let round = 0; round < ROUNDS; round++) {
+ await act(async () => {
+ h.emitNavAction({ bridgeId: ROOT_BRIDGE_ID, name: 'navigateTo', params: { url: `/${DETAIL}` } })
+ })
+ await settle()
+ expect(knownCount(ctrl), `round ${round}: pushing a page must register its orientation state`).toBe(baseline + 1)
+
+ await act(async () => {
+ h.emitNavAction({ bridgeId: 'irrelevant', name: 'navigateBack', params: { delta: 1 } })
+ })
+ await settle()
+ expect(knownCount(ctrl), `round ${round}: popping it back must release that state, not accumulate it`).toBe(baseline)
+ }
+ })
+
+ it('returns to its baseline page count after repeated redirectTo/reLaunch churn', async () => {
+ const spy = captureController()
+ const h = makeMiniApp()
+ mountShell(h)
+ await settle()
+
+ const ctrl = spy.get()
+ const baseline = knownCount(ctrl)
+
+ const ROUNDS = 5
+ for (let round = 0; round < ROUNDS; round++) {
+ await act(async () => {
+ h.emitNavAction({ bridgeId: 'irrelevant', name: 'redirectTo', params: { url: `/${DETAIL}` } })
+ })
+ await settle()
+ expect(knownCount(ctrl), `round ${round}: redirectTo replaces the top in place, count must not grow`).toBe(baseline)
+
+ await act(async () => {
+ h.emitNavAction({ bridgeId: 'irrelevant', name: 'reLaunch', params: { url: '/pages/home/home' } })
+ })
+ await settle()
+ expect(knownCount(ctrl), `round ${round}: reLaunch tears down every prior page, count must fall back to one`).toBe(baseline)
+ }
+ })
+
+ /**
+ * switchTab is the one route that LEAVES a page alive, cached inside a tab substack, instead of tearing it down — so its count contract is not "returns to baseline" but "grows by exactly what got cached, and a cache restore neither duplicates a registration nor releases a substack it didn't touch". reLaunch is the one route that tears every substack down regardless of which tab is active, so it is what brings the count back to one at the end.
+ */
+ it('grows and restores precisely across switchTab, and reLaunch releases every cached substack', async () => {
+ const spy = captureController()
+ const h = makeMiniApp({
+ tabBar: { list: [{ pagePath: TAB1, text: 'Tab1' }, { pagePath: TAB2, text: 'Tab2' }] },
+ rootPagePath: TAB1,
+ })
+ mountShell(h)
+ await settle()
+
+ const ctrl = spy.get()
+ const baseline = knownCount(ctrl)
+ expect(baseline, 'only the root tab page is mounted before any route runs').toBe(1)
+
+ // A tab that has never been visited must be opened fresh — and the tab switched away from must stay cached, not released.
+ await act(async () => {
+ h.emitNavAction({ bridgeId: ROOT_BRIDGE_ID, name: 'switchTab', params: { url: `/${TAB2}` } })
+ })
+ await settle()
+ expect(knownCount(ctrl), 'a freshly-opened tab grows the count by one; the tab left behind stays cached').toBe(baseline + 1)
+
+ // Switching back must restore tab1 from its cache — no re-registration — and must not release tab2's substack behind it.
+ await act(async () => {
+ h.emitNavAction({ bridgeId: 'irrelevant', name: 'switchTab', params: { url: `/${TAB1}` } })
+ })
+ await settle()
+ expect(knownCount(ctrl), 'restoring a cached tab must neither duplicate its registration nor release the other tab').toBe(baseline + 1)
+
+ // Push a page onto the active tab's own substack, then leave via switchTab: the pushed page is now cached inside that hidden substack.
+ await act(async () => {
+ h.emitNavAction({ bridgeId: ROOT_BRIDGE_ID, name: 'navigateTo', params: { url: `/${DETAIL}` } })
+ })
+ await settle()
+ expect(knownCount(ctrl), 'the pushed page registers its own orientation state').toBe(baseline + 2)
+
+ await act(async () => {
+ h.emitNavAction({ bridgeId: 'irrelevant', name: 'switchTab', params: { url: `/${TAB2}` } })
+ })
+ await settle()
+ expect(
+ knownCount(ctrl),
+ 'a page cached inside a hidden tab substack stays tracked — an unrelated switchTab must not release it',
+ ).toBe(baseline + 2)
+
+ // Repeated cache restores must not duplicate either tab root or the depth-two hidden substack.
+ // Exact count after every hop catches both leaks and premature release.
+ for (let round = 0; round < 8; round++) {
+ const target = round % 2 === 0 ? TAB1 : TAB2
+ await act(async () => {
+ h.emitNavAction({ bridgeId: 'irrelevant', name: 'switchTab', params: { url: `/${target}` } })
+ })
+ await settle()
+ expect(
+ knownCount(ctrl),
+ `switchTab round ${round}: cached depth-two substack must remain exactly accounted for`,
+ ).toBe(baseline + 2)
+ }
+
+ // reLaunch to a non-tab page tears every tab substack down in one shot, including whatever is cached inside them.
+ await act(async () => {
+ h.emitNavAction({ bridgeId: 'irrelevant', name: 'reLaunch', params: { url: '/pages/home/home' } })
+ })
+ await settle()
+ expect(knownCount(ctrl), 'reLaunch releases every tab substack and everything cached inside them').toBe(baseline)
+ })
+})
diff --git a/packages/devtools/src/simulator/device-shell/orientation-controller.test.ts b/packages/devtools/src/simulator/device-shell/orientation-controller.test.ts
new file mode 100644
index 00000000..fad10a45
--- /dev/null
+++ b/packages/devtools/src/simulator/device-shell/orientation-controller.test.ts
@@ -0,0 +1,364 @@
+import { describe, expect, it } from "vitest";
+import {
+ computeResizePayload,
+ NAV_BAR_HEIGHT,
+ OrientationController,
+ pageWindowSize,
+ tabBarReservedHeight,
+ TAB_BAR_HEIGHT,
+} from "./orientation-controller";
+
+// Notch-free: its safe-area insets are 0 in either orientation, so cases that are not about insets read the same numbers whichever way the page faces.
+const PORTRAIT_DEVICE = {
+ screenWidth: 390,
+ screenHeight: 844,
+ statusBarHeight: 47,
+ notchType: "none",
+ safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 },
+};
+
+// iPhone X profile: portrait home indicator 34, landscape 21.
+const NOTCHED_DEVICE = {
+ screenWidth: 390,
+ screenHeight: 844,
+ statusBarHeight: 44,
+ notchType: "notch",
+ safeAreaInsets: { top: 44, right: 0, bottom: 34, left: 0 },
+};
+
+/**
+ * A host report.
+ * These gating tests only vary the window size — the screen dimensions ride along in `size` for the business callbacks and never enter the dispatch verdict.
+ */
+function report(windowWidth: number, windowHeight: number) {
+ return { screenWidth: windowWidth, screenHeight: windowHeight + NAV_BAR_HEIGHT, windowWidth, windowHeight };
+}
+
+describe("OrientationController", () => {
+ describe("openPage / closePage", () => {
+ it("resolves state from the page config", () => {
+ const ctrl = new OrientationController();
+ expect(ctrl.openPage("a", "landscape")).toEqual({
+ originalPageOrientation: "landscape",
+ });
+ });
+
+ it("is idempotent for an already-known bridgeId", () => {
+ const ctrl = new OrientationController();
+ const first = ctrl.openPage("a", "auto");
+ expect(ctrl.openPage("a", "auto")).toBe(first);
+ });
+
+ it("releases tracked state", () => {
+ const ctrl = new OrientationController();
+ ctrl.openPage("a", "auto");
+ ctrl.buildResizePayload("s1", "a", "portrait", report(390, 700));
+ ctrl.closePage("a");
+ expect(ctrl.getState("a")).toBeUndefined();
+ expect(() =>
+ ctrl.buildResizePayload("s1", "a", "portrait", report(390, 700)),
+ ).toThrow(/unknown bridgeId/);
+ });
+
+ it("lists every tracked bridgeId", () => {
+ const ctrl = new OrientationController();
+ ctrl.openPage("a", "auto");
+ ctrl.openPage("b", "landscape");
+ expect(Array.from(ctrl.knownBridgeIds()).sort()).toEqual(["a", "b"]);
+ });
+ });
+
+ describe("effectiveFor", () => {
+ it("an auto page follows the device orientation", () => {
+ const ctrl = new OrientationController();
+ ctrl.openPage("a", "auto");
+ expect(ctrl.effectiveFor("a", "portrait")).toBe("portrait");
+ expect(ctrl.effectiveFor("a", "landscape")).toBe("landscape");
+ });
+
+ it("a fixed-orientation page ignores the device orientation", () => {
+ const ctrl = new OrientationController();
+ ctrl.openPage("a", "landscape");
+ expect(ctrl.effectiveFor("a", "portrait")).toBe("landscape");
+ expect(ctrl.effectiveFor("a", "landscape")).toBe("landscape");
+ });
+
+ it("falls back to the device orientation for an unregistered page", () => {
+ const ctrl = new OrientationController();
+ expect(ctrl.effectiveFor("ghost", "landscape")).toBe("landscape");
+ });
+
+ });
+
+ describe("buildResizePayload", () => {
+ it("throws for an unregistered bridgeId", () => {
+ const ctrl = new OrientationController();
+ expect(() =>
+ ctrl.buildResizePayload("s1", "ghost", "portrait", report(1, 1)),
+ ).toThrow(/unknown bridgeId/);
+ });
+
+ it("dispatches both channels on the first frame of a fresh app lifetime", () => {
+ // The app-global baseline starts empty, so the first geometry of a lifetime is a change for the window channel too.
+ const ctrl = new OrientationController();
+ ctrl.openPage("a", "auto");
+ const payload = ctrl.buildResizePayload("s1", "a", "portrait", report(390, 700));
+ expect(payload).toMatchObject({
+ appSessionId: "s1",
+ bridgeId: "a",
+ deviceOrientation: "portrait",
+ dispatchWindow: true,
+ dispatchPage: true,
+ canRotate: true,
+ });
+ });
+
+ it("keeps the page channel open when a report repeats the geometry, and closes only the window channel", () => {
+ const ctrl = new OrientationController();
+ ctrl.openPage("a", "auto");
+ ctrl.buildResizePayload("s1", "a", "portrait", report(390, 700));
+ const second = ctrl.buildResizePayload("s1", "a", "landscape", report(844, 320));
+ expect(second.dispatchWindow).toBe(true);
+ expect(second.dispatchPage).toBe(true);
+ expect(second.deviceOrientation).toBe("landscape");
+ // Same geometry again: the app-global baseline did not move, so the window channel closes.
+ // The page channel carries whichever page is being reported regardless of geometry.
+ const third = ctrl.buildResizePayload("s1", "a", "landscape", report(844, 320));
+ expect(third.dispatchWindow).toBe(false);
+ expect(third.dispatchPage).toBe(true);
+ });
+
+ it("gives a cached page returning to a geometry another page already reported its own Page.onResize", () => {
+ const ctrl = new OrientationController();
+ ctrl.openPage("a", "auto");
+ ctrl.openPage("b", "auto");
+ const portrait = report(390, 700);
+ const landscape = report(844, 320);
+ ctrl.buildResizePayload("s1", "a", "portrait", portrait);
+ // "b" rotates the window to landscape while "a" stays cached in portrait.
+ ctrl.buildResizePayload("s1", "b", "portrait", portrait);
+ ctrl.buildResizePayload("s1", "b", "landscape", landscape);
+ const back = ctrl.buildResizePayload("s1", "a", "landscape", landscape);
+ // The window has not moved since "b" reported it, so only the page that is now on screen hears about it.
+ expect(back.dispatchWindow).toBe(false);
+ expect(back.dispatchPage).toBe(true);
+ });
+
+ it("stays silent on both channels for a page pinned to a fixed orientation", () => {
+ const ctrl = new OrientationController();
+ ctrl.openPage("a", "landscape");
+ const first = ctrl.buildResizePayload("s1", "a", "portrait", report(844, 320));
+ expect(first.dispatchWindow).toBe(false);
+ expect(first.dispatchPage).toBe(false);
+ expect(first.canRotate).toBe(false);
+ });
+ });
+});
+
+describe("tabBarReservedHeight", () => {
+ it("adds the content-box row height, the bottom safe-area padding, and the 1px border-top", () => {
+ // tab-bar.css: box-sizing:content-box + border-top:1px; tab-bar.tsx: inline padding-bottom = bottomInset.
+ // A home-button device has bottomInset 0.
+ expect(tabBarReservedHeight(0)).toBe(TAB_BAR_HEIGHT + 1);
+ expect(tabBarReservedHeight(34)).toBe(TAB_BAR_HEIGHT + 34 + 1);
+ });
+});
+
+describe("pageWindowSize", () => {
+ const oriented = { screenWidth: 390, screenHeight: 844, statusBarHeight: 47 };
+
+ it("subtracts the status bar and nav bar for a default non-tab page", () => {
+ expect(
+ pageWindowSize(oriented, {
+ navigationStyle: "default",
+ isTab: false,
+ bottomInset: 0,
+ }),
+ ).toEqual({
+ windowWidth: 390,
+ windowHeight: 844 - 47 - NAV_BAR_HEIGHT,
+ });
+ });
+
+ it("subtracts the REAL tab-bar-reserved height (row + bottom inset + border), not just the 50px row", () => {
+ expect(
+ pageWindowSize(oriented, {
+ navigationStyle: "default",
+ isTab: true,
+ bottomInset: 34,
+ }),
+ ).toEqual({
+ windowWidth: 390,
+ windowHeight: 844 - 47 - NAV_BAR_HEIGHT - tabBarReservedHeight(34),
+ });
+ });
+
+ it("navigationStyle: custom reserves NEITHER the status bar NOR the nav bar — both are position:absolute overlays (status-bar.css always; navigation-bar.css .nav-bar--custom)", () => {
+ expect(
+ pageWindowSize(oriented, {
+ navigationStyle: "custom",
+ isTab: false,
+ bottomInset: 0,
+ }),
+ ).toEqual({
+ windowWidth: 390,
+ windowHeight: 844,
+ });
+ });
+
+ it("a custom-nav tabBar page still reserves the real tab bar height", () => {
+ expect(
+ pageWindowSize(oriented, {
+ navigationStyle: "custom",
+ isTab: true,
+ bottomInset: 34,
+ }),
+ ).toEqual({
+ windowWidth: 390,
+ windowHeight: 844 - tabBarReservedHeight(34),
+ });
+ });
+
+ it("never returns a negative height", () => {
+ const tiny = { screenWidth: 100, screenHeight: 50, statusBarHeight: 47 };
+ expect(
+ pageWindowSize(tiny, {
+ navigationStyle: "default",
+ isTab: true,
+ bottomInset: 34,
+ }).windowHeight,
+ ).toBe(0);
+ });
+});
+
+describe("computeResizePayload", () => {
+ it("swaps dimensions and drops the status bar for a landscape auto page", () => {
+ const ctrl = new OrientationController();
+ ctrl.openPage("a", "auto");
+ const payload = computeResizePayload(
+ ctrl,
+ "s1",
+ { bridgeId: "a", reservesTabBar: false, navBarStyle: "default" },
+ PORTRAIT_DEVICE,
+ "landscape",
+ );
+ expect(payload.deviceOrientation).toBe("landscape");
+ // Both pairs swap together, and the screen keeps the chrome the window gives up — that difference is the whole reason a host reports both.
+ expect(payload.size).toEqual({
+ screenWidth: 844,
+ screenHeight: 390,
+ windowWidth: 844,
+ windowHeight: 390 - NAV_BAR_HEIGHT,
+ });
+ expect(payload.canRotate).toBe(true);
+ });
+
+ it("keeps a fixed-orientation page silent on its first frame even though it visibly differs from the device", () => {
+ const ctrl = new OrientationController();
+ ctrl.openPage("a", "landscape");
+ const payload = computeResizePayload(
+ ctrl,
+ "s1",
+ { bridgeId: "a", reservesTabBar: true, navBarStyle: "default" },
+ NOTCHED_DEVICE,
+ "portrait",
+ );
+ expect(payload.deviceOrientation).toBe("landscape");
+ expect(payload.dispatchWindow).toBe(false);
+ expect(payload.dispatchPage).toBe(false);
+ expect(payload.canRotate).toBe(false);
+ // The page is displayed landscape even though the device is portrait, so it reserves the LANDSCAPE home indicator (21), not the device's 34.
+ expect(payload.size).toEqual({
+ // The screen follows the orientation the page SHOWS, not the device's own.
+ screenWidth: 844,
+ screenHeight: 390,
+ windowWidth: 844,
+ windowHeight: 390 - NAV_BAR_HEIGHT - tabBarReservedHeight(21),
+ });
+ });
+
+ it("shares the geometry baseline across bridgeIds: the same geometry reported for a different page is not a change", () => {
+ const ctrl = new OrientationController();
+ ctrl.openPage("a", "auto");
+ ctrl.openPage("b", "auto");
+
+ const first = computeResizePayload(
+ ctrl,
+ "s1",
+ { bridgeId: "a", reservesTabBar: false, navBarStyle: "default" },
+ PORTRAIT_DEVICE,
+ "landscape",
+ );
+ expect(first.dispatchWindow).toBe(true);
+
+ const second = computeResizePayload(
+ ctrl,
+ "s1",
+ { bridgeId: "b", reservesTabBar: false, navBarStyle: "default" },
+ PORTRAIT_DEVICE,
+ "landscape",
+ );
+ expect(second.dispatchWindow).toBe(false);
+ // The page channel is not baseline-driven: it carries whichever page is being reported, so "b" still gets its own Page.onResize.
+ expect(second.dispatchPage).toBe(true);
+ });
+
+
+ it("silences only the window channel when the same geometry is recomputed for an auto page", () => {
+ const ctrl = new OrientationController();
+ ctrl.openPage("a", "auto");
+
+ const first = computeResizePayload(
+ ctrl,
+ "s1",
+ { bridgeId: "a", reservesTabBar: false, navBarStyle: "default" },
+ PORTRAIT_DEVICE,
+ "landscape",
+ );
+ expect(first.dispatchWindow).toBe(true);
+ expect(first.dispatchPage).toBe(true);
+
+ const second = computeResizePayload(
+ ctrl,
+ "s1",
+ { bridgeId: "a", reservesTabBar: false, navBarStyle: "default" },
+ PORTRAIT_DEVICE,
+ "landscape",
+ );
+ expect(second.dispatchWindow).toBe(false);
+ expect(second.dispatchPage).toBe(true);
+ });
+
+ it("reserves the tab bar at the page's own effective orientation's inset, not the device's portrait baseline", () => {
+ const ctrl = new OrientationController();
+ ctrl.openPage("a", "auto");
+ const target = {
+ bridgeId: "a",
+ reservesTabBar: true,
+ navBarStyle: "default" as const,
+ };
+
+ const portrait = computeResizePayload(
+ ctrl,
+ "s1",
+ target,
+ NOTCHED_DEVICE,
+ "portrait",
+ );
+ expect(portrait.size.windowHeight).toBe(
+ 844 - 44 - NAV_BAR_HEIGHT - tabBarReservedHeight(34),
+ );
+
+ // The landscape home indicator is thinner (21), so the tab bar gives 13px back — the same number the spawn seed derives from the oriented host env.
+ const landscape = computeResizePayload(
+ ctrl,
+ "s1",
+ target,
+ NOTCHED_DEVICE,
+ "landscape",
+ );
+ expect(landscape.size.windowHeight).toBe(
+ 390 - NAV_BAR_HEIGHT - tabBarReservedHeight(21),
+ );
+ });
+});
diff --git a/packages/devtools/src/simulator/device-shell/orientation-controller.ts b/packages/devtools/src/simulator/device-shell/orientation-controller.ts
new file mode 100644
index 00000000..7ebbd4dc
--- /dev/null
+++ b/packages/devtools/src/simulator/device-shell/orientation-controller.ts
@@ -0,0 +1,150 @@
+/**
+ * DeviceShell's authority over screen orientation (see shared/page-orientation.ts for the semantics this wraps).
+ * One `PageOrientationState` lives per bridgeId for as long as that page stays mounted (visible or cached in a tab substack); DeviceShell registers/releases entries as pages open and close, reads the current top page's effective orientation to size the phone shell, and reports resize payloads gated the same way WeChat's base library gates `Page.onResize` / `wx.onWindowResize`.
+ *
+ * Effective orientation is a pure function of page configuration and device orientation.
+ * Bringing a cached page back to the foreground recomputes from its own immutable configuration without an explicit restore step.
+ */
+import {
+ canUserRotate,
+ EMPTY_RESIZE_BASELINE,
+ effectiveOrientation,
+ orientedDeviceMetrics,
+ orientedSafeAreaInsets,
+ pageWindowSize,
+ resolvePageOrientationState,
+ shouldDispatchResize,
+ type DeviceMetricsInput,
+ type Orientation,
+ type PageOrientationState,
+ type PageResizePayload,
+ type ResizeBaseline,
+ type ResizeReportSize,
+ type SafeAreaInput,
+} from '@dimina-kit/electron-runtime/shared/page-orientation'
+
+/**
+ * The chrome geometry the phone shell renders is the same formula the router seeds a spawn's host env with — one implementation in shared/page-orientation.ts, re-exported here for the shell-side callers.
+ */
+export {
+ NAV_BAR_HEIGHT,
+ pageWindowSize,
+ TAB_BAR_HEIGHT,
+ tabBarReservedHeight,
+} from '@dimina-kit/electron-runtime/shared/page-orientation'
+
+export class OrientationController {
+ private readonly states = new Map()
+ /** App-global geometry baseline, shared by every bridgeId — see shouldDispatchResize's module doc. */
+ private lastDispatched: ResizeBaseline = EMPTY_RESIZE_BASELINE
+ /** Registers a freshly-mounted page's orientation state from its resolved window config. Re-registering an already-known bridgeId is a no-op — a page's config never changes after it opens. */
+ openPage(bridgeId: string, pageOrientation: unknown): PageOrientationState {
+ const existing = this.states.get(bridgeId)
+ if (existing) return existing
+ const state = resolvePageOrientationState(pageOrientation)
+ this.states.set(bridgeId, state)
+ return state
+ }
+
+ /** Releases a torn-down page's orientation config so the map never outlives its page. */
+ closePage(bridgeId: string): void {
+ this.states.delete(bridgeId)
+ }
+
+ /** Bridge ids this controller currently tracks — used to diff against the live mounted set. */
+ knownBridgeIds(): IterableIterator {
+ return this.states.keys()
+ }
+
+ getState(bridgeId: string): PageOrientationState | undefined {
+ return this.states.get(bridgeId)
+ }
+
+ /** What `bridgeId` should currently show. Falls back to the device orientation for an unregistered page (shouldn't happen once `openPage` runs before first paint). */
+ effectiveFor(bridgeId: string, deviceOrientation: Orientation): Orientation {
+ const state = this.states.get(bridgeId)
+ return state ? effectiveOrientation(state, deviceOrientation) : deviceOrientation
+ }
+
+ /**
+ * Build the `PAGE_RESIZE` payload for `bridgeId` at its current effective orientation. `dispatchWindow`/`dispatchPage` follow the gating rules (`shouldDispatchResize`): the window channel fires on a change against the app-global baseline, the page channel carries whichever page this report names without any geometry comparison, and both are silent together for a fixed-orientation page.
+ * Every report records the app-global baseline whether or not it dispatched, so a suppressed report still becomes the next comparison's basis.
+ */
+ buildResizePayload(
+ appSessionId: string,
+ bridgeId: string,
+ deviceOrientation: Orientation,
+ size: ResizeReportSize,
+ ): PageResizePayload {
+ const state = this.states.get(bridgeId)
+ if (!state) {
+ throw new Error(`[orientation-controller] buildResizePayload: unknown bridgeId ${bridgeId}`)
+ }
+ const effective = effectiveOrientation(state, deviceOrientation)
+ const next = { ...size, deviceOrientation: effective }
+ const { dispatchWindow, dispatchPage } = shouldDispatchResize({ state, previous: this.lastDispatched, next })
+ this.lastDispatched = next
+ return {
+ appSessionId,
+ bridgeId,
+ size,
+ deviceOrientation: effective,
+ dispatchWindow,
+ dispatchPage,
+ canRotate: canUserRotate(state),
+ }
+ }
+}
+
+/** The subset of a `PageEntry` a resize computation needs — kept structural so this module doesn't depend on page-stack-controller's types. */
+export interface ResizeTargetPage {
+ bridgeId: string
+ /**
+ * Whether the tab bar currently takes layout space away from this page — `page.isTab && tabBarState.visible`, NOT `page.isTab` alone: `wx.hideTabBar` unmounts the bar and hands its height back to the page viewport.
+ */
+ reservesTabBar: boolean
+ navBarStyle: 'default' | 'custom'
+}
+
+/**
+ * A device profile as the shell holds it: portrait-baseline metrics plus the notch descriptor.
+ * Takes `notchType` rather than `SafeAreaInput`'s `hasNotch` so callers hand over `NativeDeviceInfo` untouched and the one boolean the inset formula needs is derived in a single place.
+ */
+export type ResizeDeviceProfile = DeviceMetricsInput & {
+ notchType: string
+ safeAreaInsets: SafeAreaInput['safeAreaInsets']
+}
+
+/**
+ * Full "did the visible page's geometry change" pipeline: derive the page's window size at its effective orientation, record it as on-screen, and build the gated resize payload.
+ * DeviceShell's one call site for every trigger — route change or device rotation.
+ */
+export function computeResizePayload(
+ ctrl: OrientationController,
+ appSessionId: string,
+ page: ResizeTargetPage,
+ device: ResizeDeviceProfile,
+ deviceOrientation: Orientation,
+): PageResizePayload {
+ const effective = ctrl.effectiveFor(page.bridgeId, deviceOrientation)
+ // The tab bar reserves the inset the page is actually displayed with, which follows the page's effective orientation — a notched phone's landscape home indicator is thinner than its portrait one.
+ // Deriving it here rather than taking it as a parameter keeps it the same rule the spawn seed applies (`bridge-router` feeds `withPageWindowSize` the host env's already-oriented insets), so `getSystemInfoSync().windowHeight` cannot answer one number at launch and another on the first frame.
+ const bottomInset = orientedSafeAreaInsets(
+ { ...device, hasNotch: device.notchType !== 'none' },
+ effective,
+ ).bottom
+ const oriented = orientedDeviceMetrics(device, effective)
+ const window = pageWindowSize(oriented, {
+ navigationStyle: page.navBarStyle,
+ // `PageChrome.isTab` means "the tab bar is in the layout flow below this page", which on a live shell also depends on whether it is hidden.
+ isTab: page.reservesTabBar,
+ bottomInset,
+ })
+ // The screen dimensions ride along with the window ones: the base library hands `size` to the callbacks untouched, and the native hosts put both pairs in it.
+ const size = {
+ screenWidth: oriented.screenWidth,
+ screenHeight: oriented.screenHeight,
+ ...window,
+ }
+ return ctrl.buildResizePayload(appSessionId, page.bridgeId, deviceOrientation, size)
+}
diff --git a/packages/devtools/src/simulator/device-shell/use-orientation.test.tsx b/packages/devtools/src/simulator/device-shell/use-orientation.test.tsx
new file mode 100644
index 00000000..da1ce05f
--- /dev/null
+++ b/packages/devtools/src/simulator/device-shell/use-orientation.test.tsx
@@ -0,0 +1,264 @@
+/**
+ * `useOrientation` — the shell-side landing of every geometry trigger.
+ *
+ * Guards these invariants:
+ * - the top page is registered before its geometry is read, so the metrics the
+ * shell renders at describe the page that is actually on top even on the very first render that receives it;
+ * - `publishTopResize` lets a synchronous route publish the incoming page's
+ * geometry ahead of the lifecycle events it dispatches;
+ * - the reported window height follows the tab bar's VISIBILITY, not merely
+ * the page's tab-route flag, because `wx.hideTabBar` hands its reserved height back to the page.
+ */
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import {
+ NAV_BAR_HEIGHT,
+ tabBarReservedHeight,
+} from "@dimina-kit/electron-runtime/shared/page-orientation";
+import type {
+ PageOrientationConfig,
+ PageResizePayload,
+} from "@dimina-kit/electron-runtime/shared/page-orientation";
+import type { NativeDeviceInfo } from "../../shared/ipc-channels";
+import type {
+ MountedEntry,
+ PageEntry,
+} from "@dimina-kit/electron-runtime/simulator-ui";
+import { useOrientation } from "./use-orientation";
+
+const DEVICE: NativeDeviceInfo = {
+ brand: "Apple",
+ model: "iPhone 14",
+ system: "iOS 16.0",
+ platform: "ios",
+ pixelRatio: 3,
+ screenWidth: 390,
+ screenHeight: 844,
+ statusBarHeight: 47,
+ notchType: "dynamic-island",
+ safeAreaInsets: { top: 47, right: 0, bottom: 34, left: 0 },
+ deviceOrientation: "portrait",
+};
+
+function page(
+ bridgeId: string,
+ isTab: boolean,
+ pageOrientation: PageOrientationConfig = "auto",
+): PageEntry {
+ return {
+ bridgeId,
+ pagePath: `pages/${bridgeId}/${bridgeId}`,
+ query: {},
+ isTab,
+ windowConfig: { pageOrientation },
+ navBar: {
+ title: bridgeId,
+ style: "default",
+ backgroundColor: "#ffffff",
+ textStyle: "black",
+ loading: false,
+ homeButtonVisible: false,
+ },
+ };
+}
+
+interface Harness {
+ miniApp: {
+ appSessionId: string;
+ notifyResize: ReturnType;
+ };
+ resizes: () => PageResizePayload[];
+}
+
+function makeHarness(): Harness {
+ const notifyResize = vi.fn();
+ const miniApp = {
+ appSessionId: "s1",
+ notifyResize,
+ };
+ return {
+ miniApp,
+ resizes: () =>
+ notifyResize.mock.calls.map((c) => c[0] as PageResizePayload),
+ };
+}
+
+function mount(h: Harness, entries: PageEntry[], tabBarVisible: boolean) {
+ const mounted: MountedEntry[] = entries.map((entry, i) => ({
+ entry,
+ visible: i === entries.length - 1,
+ }));
+ const top = entries[entries.length - 1]!;
+ return renderHook(
+ (props: {
+ mounted: MountedEntry[];
+ top: PageEntry;
+ tabBarVisible: boolean;
+ }) =>
+ useOrientation(
+ h.miniApp as never,
+ {
+ top: props.top,
+ mounted: props.mounted,
+ tabBarVisible: props.tabBarVisible,
+ },
+ DEVICE,
+ ),
+ { initialProps: { mounted, top, tabBarVisible } },
+ );
+}
+
+beforeEach(() => {
+ vi.useRealTimers();
+});
+
+describe("useOrientation: the top page is registered before it is measured", () => {
+ it("a routed-in fixed-orientation page sizes the shell on the render that receives it", () => {
+ const h = makeHarness();
+ const tabPage = page("a", true);
+ const detail = page("b", false, "landscape");
+ const { result, rerender } = mount(h, [tabPage], true);
+
+ expect(result.current.orientedMetrics).toEqual({
+ screenWidth: 390,
+ screenHeight: 844,
+ statusBarHeight: 47,
+ });
+
+ rerender({
+ mounted: [
+ { entry: tabPage, visible: false },
+ { entry: detail, visible: true },
+ ],
+ top: detail,
+ tabBarVisible: true,
+ });
+
+ expect(
+ result.current.orientedMetrics,
+ "nothing re-renders the shell after the layout effect, so the render itself must already know the page",
+ ).toEqual({ screenWidth: 844, screenHeight: 390, statusBarHeight: 0 });
+ });
+});
+
+describe("useOrientation: publishTopResize", () => {
+ it("reports the given page as the visible top at its own effective orientation", () => {
+ const h = makeHarness();
+ const detail = page("b", false, "landscape");
+ const { result } = mount(h, [page("a", true)], true);
+ h.miniApp.notifyResize.mockClear();
+
+ act(() => result.current.publishTopResize(detail));
+
+ const payload = h.resizes().at(-1)!;
+ expect(payload.bridgeId).toBe("b");
+ expect(payload.deviceOrientation).toBe("landscape");
+ expect(payload.size).toEqual({
+ screenWidth: 844,
+ screenHeight: 390,
+ windowWidth: 844,
+ windowHeight: 390 - NAV_BAR_HEIGHT,
+ });
+ });
+
+ it("reports a restored tab page with the tab bar it reserves again", () => {
+ const h = makeHarness();
+ const tabPage = page("a", true);
+ const { result } = mount(h, [tabPage], true);
+ h.miniApp.notifyResize.mockClear();
+
+ act(() => result.current.publishTopResize(tabPage));
+
+ const payload = h.resizes().at(-1)!;
+ expect(payload.deviceOrientation).toBe("portrait");
+ expect(payload.size.windowHeight).toBe(
+ 844 - 47 - NAV_BAR_HEIGHT - tabBarReservedHeight(34),
+ );
+ expect(
+ payload.dispatchWindow,
+ "republishing an unchanged geometry must not fire another wx.onWindowResize",
+ ).toBe(false);
+ expect(
+ payload.dispatchPage,
+ "the page channel still carries the restored page, which is how it re-reads the window it came back into",
+ ).toBe(true);
+ });
+});
+
+describe("useOrientation: only a changed window republishes from the layout effect", () => {
+ it("a layout state rebuilt for a navigation-bar change publishes nothing", () => {
+ // A report refreshes main's host-env snapshot and re-emits the session orientation, so republishing behind every `setNavigationBarTitle` would make the route geometry no longer have a single publisher.
+ const h = makeHarness();
+ const first = page("a", false);
+ const { rerender } = mount(h, [first], false);
+ const before = h.resizes().length;
+ expect(before).toBeGreaterThan(0);
+
+ const renamed: PageEntry = {
+ ...first,
+ navBar: { ...first.navBar, title: "a new title", loading: true },
+ };
+ rerender({
+ mounted: [{ entry: renamed, visible: true }],
+ top: renamed,
+ tabBarVisible: false,
+ });
+
+ expect(h.resizes().length).toBe(before);
+ });
+
+ it("a route commit onto the same page still reports, with the window channel silent", () => {
+ // The report itself is what refreshes main's host-env snapshot and the session orientation, so it goes out on every route commit.
+ // The window channel is baseline-driven and nothing moved; the page channel carries the committed page regardless.
+ const h = makeHarness();
+ const only = page("a", false);
+ const { result } = mount(h, [only], false);
+ const before = h.resizes().length;
+
+ act(() => result.current.publishTopResize(only));
+
+ expect(h.resizes().length).toBe(before + 1);
+ expect(h.resizes().at(-1)!.dispatchPage).toBe(true);
+ expect(h.resizes().at(-1)!.dispatchWindow).toBe(false);
+ });
+});
+
+describe("useOrientation: tab bar visibility drives the reported window height", () => {
+ it("hiding the tab bar hands its reserved height back to the page", () => {
+ const h = makeHarness();
+ const tabPage = page("a", true);
+ const { rerender } = mount(h, [tabPage], true);
+
+ const withBar = h.resizes().at(-1)!;
+ expect(withBar.size.windowHeight).toBe(
+ 844 - 47 - NAV_BAR_HEIGHT - tabBarReservedHeight(34),
+ );
+
+ rerender({
+ mounted: [{ entry: tabPage, visible: true }],
+ top: tabPage,
+ tabBarVisible: false,
+ });
+
+ const withoutBar = h.resizes().at(-1)!;
+ expect(withoutBar.size.windowHeight).toBe(844 - 47 - NAV_BAR_HEIGHT);
+ expect(withoutBar.dispatchWindow).toBe(true);
+ expect(withoutBar.dispatchPage).toBe(true);
+ });
+
+ it("a non-tab page is unaffected by the tab bar flag", () => {
+ const h = makeHarness();
+ const plain = page("a", false);
+ const { rerender } = mount(h, [plain], true);
+ const before = h.resizes().at(-1)!.size.windowHeight;
+
+ rerender({
+ mounted: [{ entry: plain, visible: true }],
+ top: plain,
+ tabBarVisible: false,
+ });
+
+ expect(h.resizes().at(-1)!.size.windowHeight).toBe(before);
+ });
+});
+
diff --git a/packages/devtools/src/simulator/device-shell/use-orientation.ts b/packages/devtools/src/simulator/device-shell/use-orientation.ts
new file mode 100644
index 00000000..2d361f75
--- /dev/null
+++ b/packages/devtools/src/simulator/device-shell/use-orientation.ts
@@ -0,0 +1,175 @@
+import { useCallback, useLayoutEffect, useRef, useState } from 'react'
+import {
+ orientedDeviceMetrics,
+ orientedSafeAreaInsets,
+ type Orientation,
+ type OrientedMetrics,
+} from '@dimina-kit/electron-runtime/shared/page-orientation'
+import type { NativeDeviceInfo } from '../../shared/ipc-channels'
+import type { SimulatorMiniApp } from '../simulator-mini-app'
+import type { MiniAppFrameLayoutState } from '@dimina-kit/electron-runtime/simulator-ui'
+import {
+ computeResizePayload,
+ OrientationController,
+} from './orientation-controller'
+
+export interface UseOrientationResult {
+ /** Oriented device metrics for the current top page; null before a device is known. */
+ orientedMetrics: OrientedMetrics | null
+ /**
+ * Bottom safe-area inset at the top page's effective orientation — the same number the page's window height is computed against, so the chrome the shell paints there and the room the page is told it has agree.
+ */
+ orientedBottomInset: number
+ /**
+ * Publish the authoritative `PAGE_RESIZE` for `page` as the visible top page, right now.
+ * A synchronous route calls this before dispatching the lifecycle events of the transition, so `onShow` reads a host-env snapshot that already describes the page being shown.
+ */
+ publishTopResize: (page: MiniAppFrameLayoutState['top'], tabBarVisible?: boolean) => void
+ /**
+ * Advance the device the next synchronous publish resolves geometry against.
+ * DEVICE_CHANGE and a route can arrive in the same batch, and routing publishes before React commits — so the device the shell reports has to move the instant the change arrives, not one commit later.
+ */
+ applyDevice: (device: NativeDeviceInfo | null) => void
+}
+
+/**
+ * Everything that goes into a resize report.
+ * Two reports built from identical inputs would carry identical geometry, so re-publishing one only adds a `Page.onResize` the host never had a reason to send.
+ */
+interface ResizeInputs {
+ bridgeId: string
+ reservesTabBar: boolean
+ navBarStyle: MiniAppFrameLayoutState['top']['navBar']['style']
+ device: NativeDeviceInfo
+ deviceOrientation: Orientation
+}
+
+function sameResizeInputs(previous: ResizeInputs | null, next: ResizeInputs): boolean {
+ return previous !== null
+ && previous.bridgeId === next.bridgeId
+ && previous.reservesTabBar === next.reservesTabBar
+ && previous.navBarStyle === next.navBarStyle
+ && previous.device === next.device
+ && previous.deviceOrientation === next.deviceOrientation
+}
+
+/** Geometry facts used by synchronous route commits. */
+interface LiveSnapshot {
+ mounted: MiniAppFrameLayoutState['mounted']
+ device: NativeDeviceInfo | null
+ deviceOrientation: Orientation
+ tabBarVisible: boolean
+}
+
+/**
+ * DeviceShell's orientation glue: owns the `OrientationController` instance, keeps its tracked pages in sync with what's mounted, reports the visible top page's geometry to main on every relevant change (route, device rotation), and returns the metrics DeviceShell renders the phone shell at.
+ * See orientation-controller.ts for the underlying pure, unit-tested semantics this wires into React.
+ */
+export function useOrientation(
+ miniApp: SimulatorMiniApp,
+ layout: MiniAppFrameLayoutState,
+ device: NativeDeviceInfo | null,
+): UseOrientationResult {
+ const { top, mounted, tabBarVisible } = layout
+ const deviceOrientation: Orientation = device?.deviceOrientation ?? 'portrait'
+
+ const [orientation] = useState(() => new OrientationController())
+ // The top page is registered during RENDER, before `orientedMetrics` below reads it.
+ // Registering only from the layout effect would measure every freshly routed page one render too late: the shell would paint the orientation of the page it replaced and nothing would schedule the render that corrects it. `openPage` returns the existing state for a page it already tracks, so repeating it every render (twice under StrictMode) changes nothing.
+ orientation.openPage(top.bridgeId, top.windowConfig.pageOrientation)
+ // The live snapshot advances in the same layout effect that reconciles the controller's page ledger, so synchronous route commits cannot publish against a page set that has already been torn down.
+ const liveRef = useRef({ mounted, device, deviceOrientation, tabBarVisible })
+ const publishedRef = useRef(null)
+
+ useLayoutEffect(() => {
+ liveRef.current = { mounted, device, deviceOrientation, tabBarVisible }
+ const live = new Set(mounted.map(m => m.entry.bridgeId))
+ for (const { entry } of mounted) {
+ orientation.openPage(entry.bridgeId, entry.windowConfig.pageOrientation)
+ }
+ for (const bridgeId of orientation.knownBridgeIds()) {
+ if (!live.has(bridgeId)) orientation.closePage(bridgeId)
+ }
+ if (!device) return
+ const inputs: ResizeInputs = {
+ bridgeId: top.bridgeId,
+ reservesTabBar: top.isTab && tabBarVisible,
+ navBarStyle: top.navBar.style,
+ device,
+ deviceOrientation,
+ }
+ // The layout state is rebuilt for anything the frame renders — a navigation bar title, a loading spinner — and none of that moves the page's window.
+ // Publishing per layout object would put a `Page.onResize` behind `setNavigationBarTitle`, since the page channel is not geometry-deduped downstream (see shouldDispatchResize).
+ // Route commits publish through `publishTopResize` and record their inputs here, so the effect that follows one of them stays quiet.
+ if (sameResizeInputs(publishedRef.current, inputs)) return
+ publishedRef.current = inputs
+ miniApp.notifyResize(computeResizePayload(
+ orientation,
+ miniApp.appSessionId ?? '',
+ { bridgeId: inputs.bridgeId, reservesTabBar: inputs.reservesTabBar, navBarStyle: inputs.navBarStyle },
+ device,
+ deviceOrientation,
+ ))
+ }, [mounted, orientation, deviceOrientation, device, miniApp, top, tabBarVisible])
+
+ // Routing is synchronous: the reducer decides the new top and immediately dispatches its lifecycle events.
+ // The resize has to ride that same tick — the layout effect below only runs after React commits, by which time the page has already read its metrics in `onShow`, and a restored page whose own size did not change would never get a corrective `onResize`.
+ // Reads the live snapshot rather than this render's closure so the caller cannot publish against a device selection that has already moved on.
+ const applyDevice = useCallback((next: NativeDeviceInfo | null) => {
+ liveRef.current = {
+ ...liveRef.current,
+ device: next,
+ deviceOrientation: next?.deviceOrientation ?? 'portrait',
+ }
+ }, [])
+
+ const publishTopResize = useCallback((
+ page: MiniAppFrameLayoutState['top'],
+ committedTabBarVisible?: boolean,
+ ) => {
+ const snap = liveRef.current
+ const visibleTabBar = committedTabBarVisible ?? snap.tabBarVisible
+ if (committedTabBarVisible !== undefined) {
+ publishedRef.current = snap.device
+ ? {
+ bridgeId: page.bridgeId,
+ reservesTabBar: page.isTab && committedTabBarVisible,
+ navBarStyle: page.navBar.style,
+ device: snap.device,
+ deviceOrientation: snap.deviceOrientation,
+ }
+ : null
+ const committedMounted = snap.mounted.some(item => item.entry.bridgeId === page.bridgeId)
+ ? snap.mounted.map(item => ({
+ ...item,
+ visible: item.entry.bridgeId === page.bridgeId,
+ }))
+ : [{ entry: page, visible: true }, ...snap.mounted.map(item => ({ ...item, visible: false }))]
+ liveRef.current = { ...snap, mounted: committedMounted, tabBarVisible: committedTabBarVisible }
+ }
+ if (!snap.device) return
+ orientation.openPage(page.bridgeId, page.windowConfig.pageOrientation)
+ miniApp.notifyResize(computeResizePayload(
+ orientation,
+ miniApp.appSessionId ?? '',
+ {
+ bridgeId: page.bridgeId,
+ reservesTabBar: page.isTab && visibleTabBar,
+ navBarStyle: page.navBar.style,
+ },
+ snap.device,
+ snap.deviceOrientation,
+ ))
+ }, [miniApp, orientation])
+
+ const topEffective = orientation.effectiveFor(top.bridgeId, deviceOrientation)
+ return {
+ orientedMetrics: device ? orientedDeviceMetrics(device, topEffective) : null,
+ // What the shell PAINTS at the bottom (home indicator, tab-bar padding) has to be the same inset `computeResizePayload` takes out of the page's window, or the page would be told it has less room than the chrome actually occupies.
+ orientedBottomInset: device
+ ? orientedSafeAreaInsets({ ...device, hasNotch: device.notchType !== 'none' }, topEffective).bottom
+ : 0,
+ publishTopResize,
+ applyDevice,
+ }
+}
diff --git a/packages/devtools/src/simulator/simulator-api.test.ts b/packages/devtools/src/simulator/simulator-api.test.ts
index e040b075..7c5c420d 100644
--- a/packages/devtools/src/simulator/simulator-api.test.ts
+++ b/packages/devtools/src/simulator/simulator-api.test.ts
@@ -11,11 +11,14 @@
* getSystemInfoSync.statusBarHeight → falls back to 0
* - safeArea.height and safeArea.bottom differ accordingly.
* The tests pin these divergent values as-is (characterization, not bug fix).
+ *
+ * `__deviceInfo` / `getDeviceMetrics()` state the PORTRAIT baseline; the reported screen geometry is that baseline resolved for the orientation the page is showing, which the mocked `.dimina-native-webview__root` rect (`WB`) states — see `resolveScreenGeometry` in simulator-api.ts.
+ * Scenes A and B are both portrait, so the baseline passes through; the landscape suites below pin the re-orientation.
*/
import { beforeEach, afterEach, describe, expect, it } from 'vitest'
-import type { MiniAppContext } from './types'
-import { getWindowInfo, getSystemInfoSync } from './simulator-api'
+import type { DeviceMetrics, MiniAppContext } from './types'
+import { getSystemSetting, getWindowInfo, getSystemInfoSync } from './simulator-api'
// ─── shared mock helpers ──────────────────────────────────────────────────────
@@ -84,13 +87,14 @@ describe('getWindowInfo', () => {
windowWidth: 300,
windowHeight: 600,
statusBarHeight: 44,
+ // Portrait-baseline device dims + insets (__deviceInfo.screenWidth/ screenHeight/safeAreaInsets), NOT the mocked viewport rect (WB).
safeArea: {
- width: 300,
- height: 556, // 600 - 44
+ width: 390,
+ height: 766, // 844 - 44 - 34
top: 44,
- bottom: 600,
+ bottom: 810, // 844 - 34
left: 0,
- right: 300,
+ right: 390,
},
})
})
@@ -122,6 +126,104 @@ describe('getWindowInfo', () => {
})
})
+// ─── Landscape: the page is showing the long edge ─────────────────────────────
+//
+// The device stays a portrait-baseline iPhone; only the viewport says the page turned.
+// Both this path and the native-host one (shared/page-resize-host-env.ts) must answer with the same coordinate system — the notch moves from the top edge to both sides, the top frees up, and the home indicator thins to 21.
+
+const NOTCHED_DEVICE: DeviceMetrics = {
+ pixelRatio: 3,
+ screenWidth: 390,
+ screenHeight: 844,
+ statusBarHeight: 44,
+ safeAreaInsets: { top: 44, right: 0, bottom: 34, left: 0 },
+ hasNotch: true,
+ deviceOrientation: 'portrait',
+}
+
+/** A context whose viewport rect and device metrics are both explicit. */
+function makeDeviceMockThis(
+ viewport: { width: number; height: number },
+ device: DeviceMetrics,
+): MiniAppContext {
+ return {
+ appId: 'test-app',
+ createCallbackFunction: (fn: unknown) => (fn ? (fn as (...a: unknown[]) => void) : undefined),
+ parent: {
+ el: {
+ querySelector: (_sel: string) => ({ getBoundingClientRect: () => ({ ...viewport }) }),
+ } as unknown as Element,
+ getStatusBarRect: () => ({ height: device.statusBarHeight }),
+ },
+ getDeviceMetrics: () => device,
+ } as unknown as MiniAppContext
+}
+
+describe('screen geometry follows the orientation the page is showing', () => {
+ afterEach(() => {
+ delete (window as Window & { __deviceInfo?: unknown }).__deviceInfo
+ })
+
+ it('rotates the device baseline and rebuilds safeArea for a landscape viewport', () => {
+ const result = getSystemInfoSync.call(makeDeviceMockThis({ width: 844, height: 390 }, NOTCHED_DEVICE))
+
+ expect(result).toMatchObject({
+ screenWidth: 844,
+ screenHeight: 390,
+ statusBarHeight: 0,
+ deviceOrientation: 'landscape',
+ safeArea: {
+ top: 0,
+ left: 44, // the notch's own depth, now on the side edges
+ right: 800, // 844 - 44
+ bottom: 369, // 390 - 21 home indicator
+ width: 756, // 844 - 44 - 44
+ height: 369,
+ },
+ })
+ })
+
+ it('reports the same landscape rect through getWindowInfo', () => {
+ const result = getWindowInfo.call(makeDeviceMockThis({ width: 844, height: 390 }, NOTCHED_DEVICE))
+
+ expect(result).toMatchObject({
+ screenWidth: 844,
+ screenHeight: 390,
+ statusBarHeight: 0,
+ safeArea: { top: 0, left: 44, right: 800, bottom: 369, width: 756, height: 369 },
+ })
+ })
+
+ it('keeps a page pinned to portrait in portrait while the simulated device is rotated', () => {
+ // DeviceShell sizes the shell from the top page's EFFECTIVE orientation, so a portrait-pinned page keeps a portrait viewport on a rotated device.
+ // Reading the toolbar's device rotation instead would report landscape geometry for a page that never turned.
+ const rotatedDevice: DeviceMetrics = { ...NOTCHED_DEVICE, deviceOrientation: 'landscape' }
+ const context = makeDeviceMockThis({ width: 390, height: 844 }, rotatedDevice)
+
+ expect(getSystemInfoSync.call(context)).toMatchObject({
+ screenWidth: 390,
+ screenHeight: 844,
+ statusBarHeight: 44,
+ deviceOrientation: 'portrait',
+ safeArea: { top: 44, left: 0, right: 390, bottom: 810, width: 390, height: 766 },
+ })
+ expect(getSystemSetting.call(context)).toMatchObject({ deviceOrientation: 'portrait' })
+ })
+
+ it('leaves a notch-less device with no side insets in landscape', () => {
+ const flatDevice: DeviceMetrics = {
+ ...NOTCHED_DEVICE,
+ hasNotch: false,
+ statusBarHeight: 24,
+ safeAreaInsets: { top: 24, right: 0, bottom: 0, left: 0 },
+ }
+
+ expect(getSystemInfoSync.call(makeDeviceMockThis({ width: 844, height: 390 }, flatDevice))).toMatchObject({
+ safeArea: { top: 0, left: 0, right: 844, bottom: 390, width: 844, height: 390 },
+ })
+ })
+})
+
describe('getSystemInfoSync', () => {
let mockThis: MiniAppContext
@@ -155,13 +257,14 @@ describe('getSystemInfoSync', () => {
fontSizeSetting: 16,
SDKVersion: '3.0.0',
deviceOrientation: 'portrait',
+ // Portrait-baseline device dims + insets, NOT the mocked viewport rect.
safeArea: {
- width: 300,
- height: 522, // 600 - 44 - 34
+ width: 390,
+ height: 766, // 844 - 44 - 34
top: 44,
- bottom: 566, // 600 - 34
+ bottom: 810, // 844 - 34
left: 0,
- right: 300,
+ right: 390,
},
})
})
diff --git a/packages/devtools/src/simulator/simulator-api.ts b/packages/devtools/src/simulator/simulator-api.ts
index 66b7c0aa..d61e90a2 100644
--- a/packages/devtools/src/simulator/simulator-api.ts
+++ b/packages/devtools/src/simulator/simulator-api.ts
@@ -6,7 +6,12 @@
* (via AppManager.registerApi → MiniApp.invokeApi).
*/
-import type { MiniAppContext } from './types'
+import {
+ normalizeDeviceOrientation,
+ orientedDeviceMetrics,
+ orientedSafeAreaInsets,
+} from '@dimina-kit/electron-runtime/shared/page-orientation'
+import type { DeviceMetrics, MiniAppContext } from './types'
import { bindCallbacks } from './simulator-api-helpers'
import {
setStorageSync,
@@ -84,21 +89,18 @@ export function canIUse(this: MiniAppContext, { success, complete }: { success?:
export function getWindowInfo(this: MiniAppContext, { success, complete }: { success?: unknown; complete?: unknown } = {}) {
const { onSuccess, onComplete } = bindCallbacks(this, { success, complete })
- const { wb, di, dev, pixelRatio, screenWidth, screenHeight, windowWidth, windowHeight } = readWindowMetrics(this)
+ const { wb, di, dev, pixelRatio, windowWidth, windowHeight } = readWindowMetrics(this)
const bar = this.parent?.getStatusBarRect?.() ?? { height: dev?.statusBarHeight ?? 0 }
- const statusBarHeight = (di['statusBarHeight'] as number | undefined) ?? bar.height
+ const portraitStatusBarHeight = (di['statusBarHeight'] as number | undefined) ?? bar.height
+ const geometry = resolveScreenGeometry(di, dev, wb, portraitStatusBarHeight)
const info = {
- pixelRatio, screenWidth, screenHeight, windowWidth, windowHeight,
- statusBarHeight,
- safeArea: {
- width: wb.width,
- height: wb.height - statusBarHeight,
- top: statusBarHeight,
- bottom: wb.height,
- left: 0,
- right: wb.width,
- },
+ pixelRatio,
+ screenWidth: geometry.screenWidth,
+ screenHeight: geometry.screenHeight,
+ windowWidth, windowHeight,
+ statusBarHeight: geometry.statusBarHeight,
+ safeArea: geometry.safeArea,
}
onSuccess?.(info)
onComplete?.()
@@ -107,12 +109,14 @@ export function getWindowInfo(this: MiniAppContext, { success, complete }: { suc
export function getSystemSetting(this: MiniAppContext, { success, complete }: { success?: unknown; complete?: unknown } = {}) {
const { onSuccess, onComplete } = bindCallbacks(this, { success, complete })
+ const { windowWidth, windowHeight } = readWindowMetrics(this)
const info = {
bluetoothEnabled: false,
locationEnabled: true,
wifiEnabled: true,
- deviceOrientation: 'portrait',
+ // Same rule as getSystemInfoSync's: what the page shows, not how the simulated device is rotated (see resolveScreenGeometry).
+ deviceOrientation: normalizeDeviceOrientation({ windowWidth, windowHeight }),
}
onSuccess?.(info)
onComplete?.()
@@ -141,34 +145,83 @@ function readWindowMetrics(miniApp: MiniAppContext) {
}
}
+/**
+ * The one place this path resolves the screen-geometry family — orientation, screen dimensions, status bar height and safeArea — so they can never describe two different orientations at once.
+ *
+ * Everything follows the orientation the page is ACTUALLY showing, which the live viewport rect states directly: DeviceShell sizes the phone shell from the top page's effective orientation (device-shell/orientation-controller.ts), so a page pinned to portrait stays portrait on a rotated device.
+ * The simulated device's own rotation (`dev.deviceOrientation`, the toolbar control) is deliberately NOT consulted here — it would hand such a page landscape geometry.
+ *
+ * safeArea follows that orientation too, the same coordinate system the native-host path uses (shared/page-resize-host-env.ts → service-host/sync-impls/system-info.ts): in landscape the notch leaves the top edge for both sides and the home indicator gets thinner.
+ * That is what WeChat itself does — its base library re-asks native for a fresh `safeArea` whenever `deviceOrientation` changes instead of transforming the portrait one, and `getSystemInfoSync` passes the current native value straight through.
+ * Keeping portrait insets next to landscape dimensions would produce a rect that matches neither.
+ *
+ * `di` (window.__deviceInfo) keeps its existing override priority over `dev` (SimulatorMiniApp.getDeviceMetrics()); both state the PORTRAIT baseline, so they are re-oriented here. `wb` is the last resort when no device model is known at all, and it is already in the current orientation — it is folded back to a portrait baseline first so the single re-orientation below cannot swap an already-swapped rect.
+ */
+function resolveScreenGeometry(
+ di: Record,
+ dev: DeviceMetrics | undefined,
+ wb: { width: number; height: number },
+ portraitStatusBarHeight: number,
+) {
+ const orientation = normalizeDeviceOrientation({ windowWidth: wb.width, windowHeight: wb.height })
+ const landscape = orientation === 'landscape'
+ const baselineWidth = (di['screenWidth'] as number | undefined) ?? dev?.screenWidth
+ ?? (landscape ? wb.height : wb.width)
+ const baselineHeight = (di['screenHeight'] as number | undefined) ?? dev?.screenHeight
+ ?? (landscape ? wb.width : wb.height)
+ const baselineInsets = (di['safeAreaInsets'] as DeviceMetrics['safeAreaInsets'] | undefined)
+ ?? dev?.safeAreaInsets
+ ?? { top: portraitStatusBarHeight, right: 0, bottom: 0, left: 0 }
+ const metrics = orientedDeviceMetrics(
+ { screenWidth: baselineWidth, screenHeight: baselineHeight, statusBarHeight: portraitStatusBarHeight },
+ orientation,
+ )
+ const insets = orientedSafeAreaInsets(
+ {
+ statusBarHeight: portraitStatusBarHeight,
+ // Without a selected device only __deviceInfo speaks, and it has no notch field: a bottom inset in portrait is a home indicator, and only screens with one have a cutout to move to the sides.
+ hasNotch: dev?.hasNotch ?? baselineInsets.bottom > 0,
+ safeAreaInsets: baselineInsets,
+ },
+ orientation,
+ )
+ return {
+ orientation,
+ screenWidth: metrics.screenWidth,
+ screenHeight: metrics.screenHeight,
+ statusBarHeight: metrics.statusBarHeight,
+ safeArea: {
+ left: insets.left,
+ top: insets.top,
+ right: metrics.screenWidth - insets.right,
+ bottom: metrics.screenHeight - insets.bottom,
+ width: metrics.screenWidth - insets.left - insets.right,
+ height: metrics.screenHeight - insets.top - insets.bottom,
+ },
+ }
+}
+
function buildSystemInfo(miniApp: MiniAppContext) {
- const { wb, di, dev, pixelRatio, screenWidth, screenHeight, windowWidth, windowHeight } = readWindowMetrics(miniApp)
- const statusBarHeight = (di['statusBarHeight'] as number | undefined) ?? dev?.statusBarHeight ?? 0
- // Bottom inset sourced from safeAreaInsets.bottom (the single source — the
- // legacy flat `safeAreaBottom` field is decommissioned).
- const bottomInset = (di['safeAreaInsets'] as { bottom?: number } | undefined)?.bottom
- ?? dev?.safeAreaInsets?.bottom ?? 0
+ const { wb, di, dev, pixelRatio, windowWidth, windowHeight } = readWindowMetrics(miniApp)
+ const portraitStatusBarHeight = (di['statusBarHeight'] as number | undefined) ?? dev?.statusBarHeight ?? 0
+ const geometry = resolveScreenGeometry(di, dev, wb, portraitStatusBarHeight)
return {
brand: di['brand'] || 'devtools',
model: di['model'] || 'devtools',
- pixelRatio, screenWidth, screenHeight, windowWidth, windowHeight,
- statusBarHeight,
+ pixelRatio,
+ screenWidth: geometry.screenWidth,
+ screenHeight: geometry.screenHeight,
+ windowWidth, windowHeight,
+ statusBarHeight: geometry.statusBarHeight,
language: 'zh_CN',
version: '8.0.5',
system: di['system'] || 'iOS 16.0',
platform: di['platform'] || 'ios',
fontSizeSetting: 16,
SDKVersion: '3.0.0',
- deviceOrientation: 'portrait',
- safeArea: {
- width: wb.width,
- height: wb.height - statusBarHeight - bottomInset,
- top: statusBarHeight,
- bottom: wb.height - bottomInset,
- left: 0,
- right: wb.width,
- },
+ deviceOrientation: geometry.orientation,
+ safeArea: geometry.safeArea,
}
}
diff --git a/packages/devtools/src/simulator/simulator-mini-app-initial-device.test.ts b/packages/devtools/src/simulator/simulator-mini-app-initial-device.test.ts
new file mode 100644
index 00000000..9b10c656
--- /dev/null
+++ b/packages/devtools/src/simulator/simulator-mini-app-initial-device.test.ts
@@ -0,0 +1,110 @@
+/**
+ * `SimulatorMiniApp.getInitialDevice()` must report the device that is selected NOW, not the one frozen into the native-host bridge config when the simulator document loaded.
+ *
+ * DeviceShell reads it once for its very first render and only then registers its own DEVICE_CHANGE listener.
+ * A device switched between `spawn()` resolving and DeviceShell mounting reaches the app (it subscribes before spawning) but not the shell's listener, and nothing replays it — so the shell would keep drawing the boot device until the user happens to switch again.
+ */
+import { afterEach, describe, expect, it } from 'vitest'
+import { SIMULATOR_EVENTS } from '../shared/bridge-channels'
+import type { NativeDeviceInfo } from '../shared/ipc-channels'
+import { SimulatorMiniApp } from './simulator-mini-app'
+
+type Listener = (payload: unknown) => void
+
+const BOOT_DEVICE: NativeDeviceInfo = {
+ brand: 'Apple',
+ model: 'iPhone SE',
+ system: 'iOS 16.0',
+ platform: 'ios',
+ pixelRatio: 2,
+ screenWidth: 375,
+ screenHeight: 667,
+ statusBarHeight: 20,
+ notchType: 'none',
+ safeAreaInsets: { top: 20, right: 0, bottom: 0, left: 0 },
+ deviceOrientation: 'portrait',
+}
+
+const SWITCHED_DEVICE: NativeDeviceInfo = {
+ ...BOOT_DEVICE,
+ model: 'iPhone 14 Pro Max',
+ pixelRatio: 3,
+ screenWidth: 430,
+ screenHeight: 932,
+ statusBarHeight: 54,
+ notchType: 'dynamic-island',
+ safeAreaInsets: { top: 54, right: 0, bottom: 34, left: 0 },
+ deviceOrientation: 'landscape',
+}
+
+function installNativeHostMock() {
+ const listeners = new Map>()
+ const host = {
+ enabled: true,
+ device: BOOT_DEVICE,
+ spawn: async () => ({
+ appSessionId: 's1',
+ bridgeId: 'b1',
+ pagePath: 'pages/index/index',
+ resolvedPagePath: 'pages/index/index',
+ pageFallbackApplied: false,
+ serviceWcId: 1,
+ resourceBaseUrl: '',
+ root: 'main',
+ manifest: { pages: ['pages/index/index'], entryPagePath: 'pages/index/index', source: 'app-config' },
+ rootWindowConfig: {},
+ }),
+ dispose: () => {},
+ openPage: async () => ({ bridgeId: 'unused', pagePath: 'unused', windowConfig: {}, isTab: false }),
+ closePage: () => {},
+ notifyLifecycle: () => {},
+ notifyNavCallback: () => {},
+ notifyApiResponse: () => {},
+ notifyActivePage: () => {},
+ notifyPageStack: () => {},
+ notifyResize: () => {},
+ createRenderHostUrl: () => 'about:blank',
+ renderPreloadUrl: 'about:blank',
+ onSimulatorEvent: (channel: string, listener: Listener) => {
+ let set = listeners.get(channel)
+ if (!set) { set = new Set(); listeners.set(channel, set) }
+ set.add(listener)
+ return () => { set?.delete(listener) }
+ },
+ }
+ window.__diminaNativeHost = host as unknown as Window['__diminaNativeHost']
+ return {
+ emitDeviceChange: (device: NativeDeviceInfo) => {
+ for (const fn of listeners.get(SIMULATOR_EVENTS.DEVICE_CHANGE) ?? []) fn(device)
+ },
+ }
+}
+
+afterEach(() => {
+ delete (window as { __diminaNativeHost?: unknown }).__diminaNativeHost
+})
+
+describe('SimulatorMiniApp.getInitialDevice', () => {
+ it('returns the boot config device before any DEVICE_CHANGE arrives', async () => {
+ installNativeHostMock()
+ const app = new SimulatorMiniApp({ appId: 'a', scene: 1001, pagePath: 'pages/index/index' })
+ await app.spawn()
+
+ expect(app.getInitialDevice()).toEqual(BOOT_DEVICE)
+ })
+
+ it('returns a device switched between spawn resolving and the shell mounting', async () => {
+ const host = installNativeHostMock()
+ const app = new SimulatorMiniApp({ appId: 'a', scene: 1001, pagePath: 'pages/index/index' })
+ await app.spawn()
+
+ host.emitDeviceChange(SWITCHED_DEVICE)
+
+ expect(app.getInitialDevice()).toEqual(SWITCHED_DEVICE)
+ expect(app.getDeviceMetrics()).toMatchObject({
+ screenWidth: 430,
+ screenHeight: 932,
+ deviceOrientation: 'landscape',
+ })
+ })
+})
diff --git a/packages/devtools/src/simulator/simulator-mini-app.ts b/packages/devtools/src/simulator/simulator-mini-app.ts
index 3aa53331..287640cf 100644
--- a/packages/devtools/src/simulator/simulator-mini-app.ts
+++ b/packages/devtools/src/simulator/simulator-mini-app.ts
@@ -10,10 +10,12 @@ import type {
PageStackEntry,
PageStackPayload,
PageWindowConfig,
+ SessionActivePayload,
SpawnRequest,
SpawnResult,
TabBarConfig,
} from '../shared/bridge-channels'
+import type { PageResizePayload } from '@dimina-kit/electron-runtime/shared/page-orientation'
import type { NativeDeviceInfo } from '../shared/ipc-channels'
import type { DeviceMetrics } from './types'
@@ -35,6 +37,8 @@ interface NativeHostBridge {
notifyApiResponse(payload: ApiResponsePayload): void
notifyActivePage(payload: ActivePagePayload): void
notifyPageStack(payload: PageStackPayload): void
+ notifyResize(payload: PageResizePayload): void
+ notifySessionActive(payload: SessionActivePayload): void
createRenderHostUrl(opts: { bridgeId: string; appId: string; root: string; pagePath: string; isTab?: boolean; backgroundColor?: string }): string
renderPreloadUrl: string
device?: NativeDeviceInfo
@@ -88,10 +92,8 @@ export class SimulatorMiniApp {
private readonly apiNamespaces: string[]
/**
* Latest device delivered over SIMULATOR_EVENTS.DEVICE_CHANGE (live toolbar
- * switches). Cleared on dispose(): main's sticky device selection reaches a
- * fresh spawn through its boot config (getInitialDevice), which is always
- * re-delivered with the latest selection — a live value held across dispose
- * would shadow a newer boot config with a stale device.
+ * switches); `getInitialDevice()` prefers it over the boot config, which is a snapshot frozen at preload-install time.
+ * Cleared on dispose() along with the subscription — the next spawn re-subscribes before it requests the session, so every change from then on is observed.
*/
private currentDevice: NativeDeviceInfo | null = null
private unsubscribeDeviceChange: (() => void) | null = null
@@ -233,19 +235,32 @@ export class SimulatorMiniApp {
getNativeHost().notifyPageStack({ appSessionId, stack })
}
+ /**
+ * Report the visible top page's window geometry (PAGE_RESIZE).
+ * Main always refreshes the host-env snapshot from this; it also dispatches `pageResize` to the service host when `payload.dispatchPage` is true and fires `wx.onWindowResize` listeners when `payload.dispatchWindow` is true (DeviceShell already applied WeChat's gating — see orientation-controller.ts).
+ */
+ notifyResize(payload: PageResizePayload): void {
+ if (!this.appSessionId) return
+ getNativeHost().notifyResize(payload)
+ }
+
+ /**
+ * Claim the screen for this session.
+ * DeviceShell calls it the moment it becomes the visible shell — during a soft reload two shells are mounted and both report geometry, so main only learns which one the user sees because the visible one says so.
+ */
+ notifySessionActive(): void {
+ const appSessionId = this.appSessionId
+ if (!appSessionId) return
+ getNativeHost().notifySessionActive({ appSessionId })
+ }
+
getTabBarConfig(): TabBarConfig | null {
return this.manifest?.tabBar ?? null
}
/**
- * The app's own home page — both the target of the nav-bar home button and
- * the page its visibility rule compares the current page against. Only a
- * compiled manifest knows it: `entryPagePath`, else `pages[0]`. A 'fallback'
- * manifest is built from the spawn request itself, so its entry is whichever
- * page this session happened to launch into — reading it would let a
- * deep-linked inner page masquerade as home. That case and the no-manifest
- * case both return '', which turns the home-button rule off rather than
- * guessing a page.
+ * The app's own home page.
+ * A fallback manifest reflects the launch request, not the compiled home page, so it deliberately disables the home rule.
*/
getHomePagePath(): string {
const manifest = this.manifest
@@ -254,25 +269,18 @@ export class SimulatorMiniApp {
}
/**
- * The device selected when this simulator booted (delivered by main on the
- * native-host bridge config — the renderer pushes SetDeviceInfo before
- * AttachNative). DeviceShell uses it as the initial bezel size + notch; live
- * changes arrive over SIMULATOR_EVENTS.DEVICE_CHANGE. Null on the pre-spawn
- * default path.
+ * The newest live device selection, falling back to the boot-time bridge snapshot before DEVICE_CHANGE has been observed.
*/
getInitialDevice(): NativeDeviceInfo | null {
- return getNativeHost().device ?? null
+ return this.currentDevice ?? getNativeHost().device ?? null
}
/**
* Metric fallbacks for the simulator-resident wx.* API handlers
- * (readWindowMetrics in simulator-api.ts): the CURRENT device — a live
- * DEVICE_CHANGE wins over the boot config device — or, when no device was
- * ever selected, the host-env snapshot defaults (the same source the sync
- * service-host wx.getSystemInfoSync reports).
+ * (readWindowMetrics in simulator-api.ts): the current device, or — when no device was ever selected — the host-env snapshot defaults (the same source the sync service-host wx.getSystemInfoSync reports).
*/
getDeviceMetrics(): DeviceMetrics {
- const device = this.currentDevice ?? this.getInitialDevice()
+ const device = this.getInitialDevice()
if (device) {
return {
pixelRatio: device.pixelRatio,
@@ -280,6 +288,8 @@ export class SimulatorMiniApp {
screenHeight: device.screenHeight,
statusBarHeight: device.statusBarHeight,
safeAreaInsets: device.safeAreaInsets,
+ hasNotch: device.notchType !== 'none',
+ deviceOrientation: device.deviceOrientation ?? 'portrait',
}
}
const snap = this.getHostEnvSnapshot()
@@ -289,6 +299,8 @@ export class SimulatorMiniApp {
screenHeight: snap.screenHeight,
statusBarHeight: snap.statusBarHeight,
safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 },
+ hasNotch: false,
+ deviceOrientation: snap.deviceOrientation ?? 'portrait',
}
}
@@ -319,6 +331,7 @@ export class SimulatorMiniApp {
statusBarHeight,
language,
theme: prefersDarkMode() ? 'dark' : 'light',
+ deviceOrientation: device?.deviceOrientation ?? 'portrait',
}
}
diff --git a/packages/devtools/src/simulator/types.ts b/packages/devtools/src/simulator/types.ts
index 106b559b..f4dfacb2 100644
--- a/packages/devtools/src/simulator/types.ts
+++ b/packages/devtools/src/simulator/types.ts
@@ -1,3 +1,5 @@
+import type { Orientation } from '@dimina-kit/electron-runtime/shared/page-orientation'
+
/** Callback type used by API functions */
export type Callback = (...args: unknown[]) => void
@@ -16,6 +18,11 @@ export interface DeviceMetrics {
/** Per-edge safe-area insets (portrait). Single source of truth for the
* bottom inset — there is no separate `safeAreaBottom` field. */
safeAreaInsets: { top: number; right: number; bottom: number; left: number }
+ /** Whether the screen has a notch/dynamic island: in landscape it moves to
+ * both side edges, which is the only thing the portrait insets cannot say. */
+ hasNotch: boolean
+ /** The simulated device's own orientation (user-controlled, not the mini-app's effective one). */
+ deviceOrientation?: Orientation
}
/**
diff --git a/packages/dimina-electron-runtime/e2e/dist-assets-current.spec.ts b/packages/dimina-electron-runtime/e2e/dist-assets-current.spec.ts
new file mode 100644
index 00000000..08213fd5
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/dist-assets-current.spec.ts
@@ -0,0 +1,91 @@
+/**
+ * The whole e2e suite boots this package's `dist/`, and the parts of it that decide mini-app semantics are not built here at all: `build-assets.mjs` copies them verbatim out of `packages/devtools/dist` (the devtools build is what injects the simulator's service-API overlays into the dimina service bundle — the overlays that make sync `FileSystemManager` methods throw and that route audio/upload/WebSocket through the container).
+ *
+ * A copy left behind by an older devtools build therefore does not fail loudly: the runtime boots fine and the mini-app silently gets upstream behaviour instead of the simulator's, so unrelated-looking specs go red while the source tree is innocent.
+ * This spec restates `build-assets.mjs`'s postcondition — every copied asset is byte-identical to the devtools build it came from — so a stale copy is reported as itself.
+ *
+ * Not a substitute for building devtools: it compares the copy against `packages/devtools/dist`, so it can only catch drift between the two.
+ */
+import { test, expect } from '@playwright/test'
+import { createHash } from 'crypto'
+import fs from 'fs'
+import path from 'path'
+import { fileURLToPath } from 'url'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const PACKAGE_DIR = path.resolve(__dirname, '..')
+const DEVTOOLS_DIR = path.resolve(PACKAGE_DIR, '..', 'devtools')
+
+/** Mirrors the asset list in `build-assets.mjs`. */
+const COPIED_DIRS = ['dist/simulator', 'dist/service-host', 'dist/render-host', 'dist/native-host']
+const COPIED_FILES: Array<{ from: string, to: string }> = [
+ { from: 'dist/preload/windows/simulator.cjs', to: 'dist/preload/simulator.cjs' },
+]
+
+const REBUILD_HINT = 'run `pnpm --filter @dimina-kit/electron-runtime build:assets`'
+
+function listFiles(dir: string): string[] {
+ if (!fs.existsSync(dir)) return []
+ const out: string[] = []
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true, recursive: true })) {
+ if (!entry.isFile()) continue
+ out.push(path.relative(dir, path.join(entry.parentPath, entry.name)))
+ }
+ return out.sort()
+}
+
+function hashFile(file: string): string {
+ return createHash('sha1').update(fs.readFileSync(file)).digest('hex')
+}
+
+test.describe('dist assets copied from the devtools build', () => {
+ test('every copied asset matches its devtools source', () => {
+ expect(
+ fs.existsSync(path.join(DEVTOOLS_DIR, 'dist')),
+ 'packages/devtools/dist is missing — the comparison source has not been built',
+ ).toBe(true)
+
+ const drift: string[] = []
+ for (const rel of COPIED_DIRS) {
+ const mine = path.join(PACKAGE_DIR, rel)
+ const theirs = path.join(DEVTOOLS_DIR, rel)
+ const mineFiles = new Set(listFiles(mine))
+ const theirsFiles = listFiles(theirs)
+ for (const file of theirsFiles) {
+ if (!mineFiles.has(file)) {
+ drift.push(`${rel}/${file}: missing from this package's dist`)
+ continue
+ }
+ mineFiles.delete(file)
+ if (hashFile(path.join(mine, file)) !== hashFile(path.join(theirs, file))) {
+ drift.push(`${rel}/${file}: differs from the devtools build`)
+ }
+ }
+ for (const file of mineFiles) drift.push(`${rel}/${file}: left over, not in the devtools build`)
+ }
+ for (const { from, to } of COPIED_FILES) {
+ const mine = path.join(PACKAGE_DIR, to)
+ const theirs = path.join(DEVTOOLS_DIR, from)
+ if (!fs.existsSync(mine)) drift.push(`${to}: missing from this package's dist`)
+ else if (hashFile(mine) !== hashFile(theirs)) drift.push(`${to}: differs from ${from}`)
+ }
+
+ expect(drift, `dist assets are out of date — ${REBUILD_HINT}:\n${drift.join('\n')}`).toEqual([])
+ })
+
+ /**
+ * Matching the devtools build is not enough on its own: `build-native-host.mjs` copies whatever `dimina/fe/packages/service/dist` happens to hold, and that dist only carries the simulator's service-API overlays when it was produced by the injecting container build.
+ * A bundle built straight from upstream sources copies over just as cleanly and hands the mini-app upstream behaviour — sync FSM methods answering `undefined` instead of throwing, audio/upload/WebSocket bypassing the container backends.
+ */
+ test('the shipped service bundle carries the simulator service-API overlays', () => {
+ const overlaySource = path.join(DEVTOOLS_DIR, 'src/simulator/service-apis/file/index.js')
+ const reason = /SYNC_UNSUPPORTED_REASON\s*=\s*'([^']+)'/.exec(fs.readFileSync(overlaySource, 'utf8'))?.[1]
+ expect(reason, `could not read the overlay marker out of ${overlaySource}`).toBeTruthy()
+
+ const bundle = path.join(PACKAGE_DIR, 'dist/native-host/service/service.js')
+ expect(
+ fs.readFileSync(bundle, 'utf8').includes(reason!),
+ `${bundle} was built without the overlays — ${REBUILD_HINT}`,
+ ).toBe(true)
+ })
+})
diff --git a/packages/dimina-electron-runtime/e2e/electron-entry.js b/packages/dimina-electron-runtime/e2e/electron-entry.js
index b6d44e49..b46aaf9f 100644
--- a/packages/dimina-electron-runtime/e2e/electron-entry.js
+++ b/packages/dimina-electron-runtime/e2e/electron-entry.js
@@ -163,6 +163,24 @@ function getPageStack(appId) {
}))
}
+/**
+ * The routes the SERVICE host's own page stack currently holds (`getCurrentPages()`), resolved through the bridge so it always reads the session that owns `appId` — never a not-yet-destroyed service window from a just-closed session.
+ *
+ * `getCurrentPage` above answers a different question: it reads `pagePath` off the RENDER guest's URL, which is fixed at guest-creation time, long before the service host has booted its bundle and instantiated the root `Page`.
+ * The route APIs (`navigateTo`/`redirectTo`/`switchTab`/`reLaunch`) resolve their `url` against `router.getPageInfo().route` in the service host, so they need THIS fact, not the render guest's URL.
+ *
+ * Returns `[]` when no service host is connected yet.
+ */
+async function getServicePageRoutes(appId) {
+ const bridge = getBridge()
+ const serviceWc = bridge.getServiceWc(appId)
+ if (!serviceWc || serviceWc.isDestroyed() || serviceWc.isLoading()) return []
+ return serviceWc.executeJavaScript(`(() => {
+ if (typeof getCurrentPages !== 'function') return []
+ return getCurrentPages().map((p) => (p && p.route) || '')
+ })()`).catch(() => [])
+}
+
function getPageData(appId, path) {
const bridge = getBridge()
const bridgeId = bridge.getActiveBridgeId(appId)
@@ -216,7 +234,28 @@ function waitForActivePage(bridge, { since, timeoutMs }) {
async function runNativeHostNav(bridge, serviceWc, method, args) {
const arg = method === 'navigateBack' ? (args[0] ?? { delta: 1 }) : (args[0] ?? {})
const since = bridge.getActiveBridgeId()
- await serviceWc.executeJavaScript(`wx.${method}(${JSON.stringify(arg)})`)
+ // `executeJavaScript` does not marshal a thrown renderer error back here: any exception inside the dispatched script surfaces as Electron's fixed, detail-free "Script failed to execute" string, with the real message and stack reachable only from the service host's own console.
+ // So the script RETURNS its outcome instead of throwing, and main re-raises it with the renderer's message/stack attached.
+ // Promise semantics are preserved: the script still resolves through whatever `wx.()` returns, so a rejected nav still fails this call — just with a readable reason.
+ const outcome = await serviceWc.executeJavaScript(`(() => {
+ const describe = (e) => ({ msg: String((e && e.message) || e), stack: String((e && e.stack) || '') })
+ try {
+ if (typeof wx === 'undefined') return { ok: false, phase: 'no-wx' }
+ if (typeof wx.${method} !== 'function') return { ok: false, phase: 'no-method' }
+ return Promise.resolve(wx.${method}(${JSON.stringify(arg)})).then(
+ () => ({ ok: true }),
+ (e) => ({ ok: false, phase: 'rejected', ...describe(e) }),
+ )
+ } catch (e) {
+ return { ok: false, phase: 'threw', ...describe(e) }
+ }
+ })()`)
+ if (!outcome.ok) {
+ throw new Error(
+ `[e2e] wx.${method}(${JSON.stringify(arg)}) ${outcome.phase} in the service host`
+ + `${outcome.msg ? `: ${outcome.msg}` : ''}${outcome.stack ? `\n${outcome.stack}` : ''}`,
+ )
+ }
const timeoutMs = method === 'navigateBack' ? 1500 : 2000
await waitForActivePage(bridge, { since, timeoutMs })
return { result: undefined }
@@ -293,14 +332,27 @@ function setDeviceHook(device) {
}
}
+/**
+ * Simulate the user rotating the physical device: broadcasts DEVICE_CHANGE to every mounted DeviceShell via the runtime's public `setDevice()`, WITHOUT the direct `service-host:host-env:update` push `setDeviceHook` also does.
+ *
+ * That direct push (see setDeviceHook above) writes `deviceInfoToHostEnv(device)` straight into the running service host's snapshot — a raw overwrite that is page-orientation-UNAWARE (it derives windowWidth/windowHeight purely from the device's own orientation) and fires no dispatch/event at all.
+ * That is fine for device-SIZE e2e (native-host-device.spec.ts, which never depends on per-page orientation), but wrong for page-orientation e2e: it would stomp a fixed-orientation page's snapshot with the naive device-only geometry, and it can never be the signal that gates `Page.onResize` / `wx.onWindowResize` dispatch because it never goes through DeviceShell's own dispatch-gated PAGE_RESIZE pipeline.
+ * Orientation e2e needs the real wire: DEVICE_CHANGE -> DeviceShell recomputes effectiveOrientation from device + page state -> notifyResize -> main.
+ */
+function rotateDeviceHook(device) {
+ runtime.setDevice(device)
+}
+
globalThis.__diminaE2eHooks = {
openProject: openProjectHook,
closeProject: closeProjectHook,
getCurrentPage,
getPageStack,
+ getServicePageRoutes,
getPageData,
callWxMethod,
setDevice: setDeviceHook,
+ rotateDevice: rotateDeviceHook,
}
})()
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.js b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.js
new file mode 100644
index 00000000..6241c06e
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.js
@@ -0,0 +1 @@
+App({})
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.json b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.json
new file mode 100644
index 00000000..26d99fa8
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.json
@@ -0,0 +1,12 @@
+{
+ "pages": [
+ "pages/home/home",
+ "pages/landscape-page/landscape-page",
+ "pages/auto-page/auto-page"
+ ],
+ "window": {
+ "navigationBarTitleText": "Landscape Fixture",
+ "navigationBarBackgroundColor": "#ffffff",
+ "navigationBarTextStyle": "black"
+ }
+}
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.wxss b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.wxss
new file mode 100644
index 00000000..f58c1bb9
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.wxss
@@ -0,0 +1,25 @@
+page {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ font-size: 28rpx;
+ color: #333;
+ background-color: #f5f5f5;
+}
+
+.page-marker {
+ font-size: 40rpx;
+ font-weight: 700;
+ padding: 40rpx;
+ color: #1a1a1a;
+}
+
+.btn {
+ display: block;
+ width: 80%;
+ margin: 20rpx auto;
+ height: 80rpx;
+ line-height: 80rpx;
+ text-align: center;
+ background: #1890ff;
+ color: #fff;
+ border-radius: 12rpx;
+}
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.js b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.js
new file mode 100644
index 00000000..aa2809d9
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.js
@@ -0,0 +1,13 @@
+// pageOrientation: 'auto' — follows the simulated device's own orientation. resizeCount/lastResize record every Page.onResize call so the e2e can assert it fires exactly once per rotation, with the payload shape { size: { windowWidth, windowHeight }, deviceOrientation }.
+Page({
+ data: {
+ resizeCount: 0,
+ lastResize: null,
+ },
+ onResize(res) {
+ this.setData({
+ resizeCount: this.data.resizeCount + 1,
+ lastResize: res,
+ })
+ },
+})
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.json b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.json
new file mode 100644
index 00000000..aa38c811
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.json
@@ -0,0 +1,3 @@
+{
+ "pageOrientation": "auto"
+}
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.wxml b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.wxml
new file mode 100644
index 00000000..6c04c28e
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.wxml
@@ -0,0 +1 @@
+AUTO ORIENTATION PAGE
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.wxss b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.wxss
new file mode 100644
index 00000000..7d433da7
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.wxss
@@ -0,0 +1,3 @@
+.page-auto {
+ color: #1a1a1a;
+}
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.js b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.js
new file mode 100644
index 00000000..9ee91829
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.js
@@ -0,0 +1,9 @@
+// Default (unconfigured) page — pageOrientation falls back to app.json's window (unset here too), so this page is fixed portrait.
+Page({
+ goLandscape() {
+ wx.navigateTo({ url: '/pages/landscape-page/landscape-page' })
+ },
+ goAuto() {
+ wx.navigateTo({ url: '/pages/auto-page/auto-page' })
+ },
+})
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.json b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.json
@@ -0,0 +1 @@
+{}
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.wxml b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.wxml
new file mode 100644
index 00000000..0f6f6219
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.wxml
@@ -0,0 +1,3 @@
+HOME PAGE
+
+
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.wxss b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.wxss
new file mode 100644
index 00000000..cf099f36
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.wxss
@@ -0,0 +1,3 @@
+.page-home {
+ color: #1a1a1a;
+}
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.js b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.js
new file mode 100644
index 00000000..9094ad1c
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.js
@@ -0,0 +1,14 @@
+// pageOrientation: 'landscape' — a FIXED-orientation page.
+// A page whose computed orientation isn't 'auto' and has never called A fixed-orientation page must never receive Page.onResize, no matter how the simulated device rotates under it. resizeCount/lastResize record every onResize call so the e2e can assert that gate holds (or catch it firing).
+Page({
+ data: {
+ resizeCount: 0,
+ lastResize: null,
+ },
+ onResize(res) {
+ this.setData({
+ resizeCount: this.data.resizeCount + 1,
+ lastResize: res,
+ })
+ },
+})
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.json b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.json
new file mode 100644
index 00000000..70a2e795
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.json
@@ -0,0 +1,3 @@
+{
+ "pageOrientation": "landscape"
+}
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.wxml b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.wxml
new file mode 100644
index 00000000..a4d17b35
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.wxml
@@ -0,0 +1 @@
+LANDSCAPE PAGE
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.wxss b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.wxss
new file mode 100644
index 00000000..b7822cf1
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.wxss
@@ -0,0 +1,3 @@
+.page-landscape {
+ color: #1a1a1a;
+}
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/project.config.json b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/project.config.json
new file mode 100644
index 00000000..9640f72f
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/project.config.json
@@ -0,0 +1,5 @@
+{
+ "appid": "devtools_landscape_fixture",
+ "projectname": "devtools-landscape-fixture",
+ "description": "Fixture mini-app for e2e page-orientation (landscape) tests"
+}
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.js b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.js
new file mode 100644
index 00000000..367c9309
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.js
@@ -0,0 +1,10 @@
+// App.onLaunch runs before any page's onLoad — a synchronous wx.getSystemInfoSync() call here is the EARLIEST point the cold-start orientation seed (bridge-router.ts's resolvePageWindowConfig / resolvePageOrientationState) can be observed, before DeviceShell has even mounted to send its first PAGE_RESIZE. globalData carries the snapshot so the root page can fold it into its own page data for the e2e to read back through getPageData.
+App({
+ globalData: {},
+ onLaunch() {
+ const info = wx.getSystemInfoSync()
+ this.globalData.onLaunchWindowWidth = info.windowWidth
+ this.globalData.onLaunchWindowHeight = info.windowHeight
+ },
+})
+
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.json b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.json
new file mode 100644
index 00000000..c28349ff
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.json
@@ -0,0 +1,14 @@
+{
+ "pages": [
+ "pages/entry/entry",
+ "pages/autopage/autopage",
+ "pages/portraitpage/portraitpage",
+ "pages/mid/mid"
+ ],
+ "window": {
+ "navigationBarTitleText": "Orientation App Landscape Fixture",
+ "navigationBarBackgroundColor": "#ffffff",
+ "navigationBarTextStyle": "black",
+ "pageOrientation": "landscape"
+ }
+}
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.wxss b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.wxss
new file mode 100644
index 00000000..b4683849
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.wxss
@@ -0,0 +1,13 @@
+page {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ font-size: 28rpx;
+ color: #333;
+ background-color: #f5f5f5;
+}
+
+.page-marker {
+ font-size: 40rpx;
+ font-weight: 700;
+ padding: 40rpx;
+ color: #1a1a1a;
+}
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.js b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.js
new file mode 100644
index 00000000..551ed7ac
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.js
@@ -0,0 +1,13 @@
+// pageOrientation: 'auto' overrides the app-level 'landscape' and follows the simulated device's own orientation instead. resizeCount/lastResize record every Page.onResize call for the e2e to assert on.
+Page({
+ data: {
+ resizeCount: 0,
+ lastResize: null,
+ },
+ onResize(res) {
+ this.setData({
+ resizeCount: this.data.resizeCount + 1,
+ lastResize: res,
+ })
+ },
+})
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.json b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.json
new file mode 100644
index 00000000..aa38c811
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.json
@@ -0,0 +1,3 @@
+{
+ "pageOrientation": "auto"
+}
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.wxml b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.wxml
new file mode 100644
index 00000000..f9989895
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.wxml
@@ -0,0 +1 @@
+AUTO PAGE
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.wxss b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.wxss
new file mode 100644
index 00000000..7d433da7
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.wxss
@@ -0,0 +1,3 @@
+.page-auto {
+ color: #1a1a1a;
+}
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.js b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.js
new file mode 100644
index 00000000..4ce5b8dc
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.js
@@ -0,0 +1,29 @@
+// No page-level pageOrientation — resolves to app.json's window.pageOrientation ('landscape'), a FIXED orientation. resizeCount/lastResize record every Page.onResize call so the e2e can assert the fixed-orientation gate holds for a page that inherited its orientation rather than declaring it.
+//
+// onLoadWindowWidth/onLoadWindowHeight capture a SYNCHRONOUS wx.getSystemInfoSync() call made from onLoad itself — the earliest a page's own code can observe its geometry, before any PAGE_RESIZE from DeviceShell could have corrected a wrong cold-start seed. onLaunchWindowWidth/onLaunchWindowHeight fold in App.onLaunch's own (even earlier) synchronous reading via globalData, so both observation points the cold-start seed exists for are asserted on, not just one.
+Page({
+ data: {
+ resizeCount: 0,
+ lastResize: null,
+ onLoadWindowWidth: null,
+ onLoadWindowHeight: null,
+ onLaunchWindowWidth: null,
+ onLaunchWindowHeight: null,
+ },
+ onLoad() {
+ const info = wx.getSystemInfoSync()
+ const app = getApp()
+ this.setData({
+ onLoadWindowWidth: info.windowWidth,
+ onLoadWindowHeight: info.windowHeight,
+ onLaunchWindowWidth: app.globalData.onLaunchWindowWidth,
+ onLaunchWindowHeight: app.globalData.onLaunchWindowHeight,
+ })
+ },
+ onResize(res) {
+ this.setData({
+ resizeCount: this.data.resizeCount + 1,
+ lastResize: res,
+ })
+ },
+})
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.json b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.json
@@ -0,0 +1 @@
+{}
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.wxml b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.wxml
new file mode 100644
index 00000000..65d216d3
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.wxml
@@ -0,0 +1 @@
+ENTRY PAGE
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.wxss b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.wxss
new file mode 100644
index 00000000..b1d6b893
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.wxss
@@ -0,0 +1,3 @@
+.page-entry {
+ color: #1a1a1a;
+}
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.js b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.js
new file mode 100644
index 00000000..bd30f5fc
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.js
@@ -0,0 +1,14 @@
+// No page-level pageOrientation — resolves to app.json's window.pageOrientation ('landscape'), a FIXED orientation.
+// Used as a middle page of a three-deep stack so route tests can assert each layer resolves its own orientation independent of its neighbors. resizeCount/lastResize record every Page.onResize call for the e2e to assert on.
+Page({
+ data: {
+ resizeCount: 0,
+ lastResize: null,
+ },
+ onResize(res) {
+ this.setData({
+ resizeCount: this.data.resizeCount + 1,
+ lastResize: res,
+ })
+ },
+})
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.json b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.json
@@ -0,0 +1 @@
+{}
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.wxml b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.wxml
new file mode 100644
index 00000000..a88b5299
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.wxml
@@ -0,0 +1 @@
+MID PAGE
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.wxss b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.wxss
new file mode 100644
index 00000000..12a21414
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.wxss
@@ -0,0 +1,3 @@
+.page-mid {
+ color: #1a1a1a;
+}
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.js b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.js
new file mode 100644
index 00000000..900925a4
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.js
@@ -0,0 +1,13 @@
+// pageOrientation: 'portrait' overrides the app-level 'landscape', a FIXED orientation independent of both the app default and the device. resizeCount/lastResize record every Page.onResize call for the e2e to assert on.
+Page({
+ data: {
+ resizeCount: 0,
+ lastResize: null,
+ },
+ onResize(res) {
+ this.setData({
+ resizeCount: this.data.resizeCount + 1,
+ lastResize: res,
+ })
+ },
+})
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.json b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.json
new file mode 100644
index 00000000..3184d92b
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.json
@@ -0,0 +1,3 @@
+{
+ "pageOrientation": "portrait"
+}
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.wxml b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.wxml
new file mode 100644
index 00000000..81675a9a
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.wxml
@@ -0,0 +1 @@
+PORTRAIT PAGE
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.wxss b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.wxss
new file mode 100644
index 00000000..684cacc8
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.wxss
@@ -0,0 +1,3 @@
+.page-portrait {
+ color: #1a1a1a;
+}
diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/project.config.json b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/project.config.json
new file mode 100644
index 00000000..a56c30cd
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/project.config.json
@@ -0,0 +1,5 @@
+{
+ "appid": "devtools_orientation_app_landscape_fixture",
+ "projectname": "devtools-orientation-app-landscape-fixture",
+ "description": "Fixture mini-app for e2e app-level window.pageOrientation:landscape resolution and cold-start/exit-recovery tests"
+}
diff --git a/packages/dimina-electron-runtime/e2e/helpers.ts b/packages/dimina-electron-runtime/e2e/helpers.ts
index eaa0e23c..e9827271 100644
--- a/packages/dimina-electron-runtime/e2e/helpers.ts
+++ b/packages/dimina-electron-runtime/e2e/helpers.ts
@@ -1,3 +1,4 @@
+import { expect } from '@playwright/test'
import type { Page, ElectronApplication } from '@playwright/test'
import fs from 'fs'
import os from 'os'
@@ -174,6 +175,7 @@ export async function getCurrentPage(
* spec in isolation) the main-process CDP evaluate channel can occasionally
* fail a single round-trip with a generic "Script failed to execute" error
* that carries no further detail.
+ * That wording is Electron's fixed text for ANY renderer-side throw, so it is not by itself evidence of a load flake: a `wx.()` that throws inside the service host is reported here with its real message and stack, because `runNativeHostNav` in electron-entry.js returns the failure rather than letting it throw across that boundary.
*
* This does NOT retry: `method` here can be a NAV method (navigateTo/
* redirectTo/reLaunch/switchTab/navigateBack), and a retry from this side of
@@ -201,6 +203,53 @@ export async function callWxMethod(
}, { method, args, appId })
}
+/**
+ * Routes held by the SERVICE host's own page stack (`getCurrentPages()`), for the session that owns `appId`.
+ *
+ * This is the readiness fact `callWxMethod(…, 'navigateTo' | 'redirectTo' | 'switchTab' | 'reLaunch')` depends on, and it is NOT the same fact `getCurrentPage` reports: that one reads `pagePath` off the render guest's URL, which is fixed when the guest is created — before the service host has booted its bundle and instantiated the root `Page`.
+ * Those route APIs resolve their `url` against `router.getPageInfo().route` in the service host, so calling one while this list is still empty makes the mini-app framework dereference an undefined base route and throw.
+ *
+ * Poll this to an entry containing the expected page before the first nav of a freshly opened (or reopened) session.
+ */
+export async function getServicePageRoutes(
+ electronApp: ElectronApplication,
+ appId?: string,
+): Promise {
+ return electronApp.evaluate((_electron, appId) => {
+ const hooks = (globalThis as Record).__diminaE2eHooks as {
+ getServicePageRoutes: (appId?: string) => Promise
+ }
+ return hooks.getServicePageRoutes(appId)
+ }, appId)
+}
+
+/**
+ * Block until the session that owns `appId` can actually be navigated.
+ *
+ * A freshly opened (or reopened) project reaches "the DeviceShell is mounted and a render guest exists" several hundred ms before the SERVICE host has booted its bundle and instantiated the root `Page`.
+ * Every route API resolves its `url` against `router.getPageInfo().route` in the service host, so a nav issued in that window makes the mini-app framework dereference an undefined base route and throw — a failure that only shows up under load, and lands on whichever spec happens to be running.
+ *
+ * Call this after opening a project and before the first nav of that session.
+ */
+export async function waitForServicePageReady(
+ electronApp: ElectronApplication,
+ appId?: string,
+ timeoutMs = 20000,
+): Promise {
+ const routes = await pollUntil(
+ () => getServicePageRoutes(electronApp, appId).catch(() => [] as string[]),
+ (r) => r.some((route) => route.includes('pages/')),
+ timeoutMs,
+ 250,
+ )
+ // `pollUntil` returns its last attempt on timeout rather than throwing, so assert here: a session whose service host never instantiates a page must fail on that fact, not further downstream on the nav it breaks.
+ expect(
+ routes.some((route) => route.includes('pages/')),
+ `the service host must hold a page before any nav call; saw ${JSON.stringify(routes)}`,
+ ).toBe(true)
+ return routes
+}
+
export async function getPageData(
electronApp: ElectronApplication,
appId: string,
diff --git a/packages/dimina-electron-runtime/e2e/native-host-audio.spec.ts b/packages/dimina-electron-runtime/e2e/native-host-audio.spec.ts
index f08760af..25528bc6 100644
--- a/packages/dimina-electron-runtime/e2e/native-host-audio.spec.ts
+++ b/packages/dimina-electron-runtime/e2e/native-host-audio.spec.ts
@@ -72,6 +72,7 @@ import {
getPageData,
callWxMethod,
RENDER_GUEST_URL_MARKER,
+ waitForServicePageReady,
} from './helpers'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -159,6 +160,9 @@ test.describe('native-host audio event bridge e2e', () => {
25000,
300,
)
+ // The route APIs resolve against the SERVICE host's own page stack, which is still empty for a few hundred ms after the render guest mounts.
+ // Navigating inside that window throws in the mini-app framework.
+ await waitForServicePageReady(electronApp)
})
test.afterAll(async () => {
diff --git a/packages/dimina-electron-runtime/e2e/native-host-navigate-data.spec.ts b/packages/dimina-electron-runtime/e2e/native-host-navigate-data.spec.ts
index 34454a10..be159ed1 100644
--- a/packages/dimina-electron-runtime/e2e/native-host-navigate-data.spec.ts
+++ b/packages/dimina-electron-runtime/e2e/native-host-navigate-data.spec.ts
@@ -48,6 +48,7 @@ import {
getCurrentPage,
getPageData,
callWxMethod,
+ waitForServicePageReady,
} from './helpers'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -119,6 +120,9 @@ test.describe('native-host navigateTo target page gets a mounted service instanc
25000,
300,
)
+ // The route APIs resolve against the SERVICE host's own page stack, which is still empty for a few hundred ms after the render guest mounts.
+ // Navigating inside that window throws in the mini-app framework.
+ await waitForServicePageReady(electronApp)
})
test.afterAll(async () => {
diff --git a/packages/dimina-electron-runtime/e2e/native-host-orientation-app-config.spec.ts b/packages/dimina-electron-runtime/e2e/native-host-orientation-app-config.spec.ts
new file mode 100644
index 00000000..49793d93
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/native-host-orientation-app-config.spec.ts
@@ -0,0 +1,325 @@
+/**
+ * E2E (native-host only): app-level `window.pageOrientation` resolution and its interaction with the cold-start seed and exit/reopen recovery.
+ *
+ * Contract pinned here (see docs/landscape-support.md and bridge-router.ts's `resolvePageWindowConfig` / cold-start seed):
+ *
+ * 1. A page with no `pageOrientation` of its own inherits app.json's
+ * `window.pageOrientation`.
+ * When that app-level value is a fixed orientation, the ROOT page must already report that orientation on its very first frame — the cold-start seed uses the root page's EFFECTIVE orientation, not the raw device orientation, because DeviceShell only sends its first authoritative PAGE_RESIZE after the spawn already resolved.
+ * This is asserted from a SYNCHRONOUS `wx.getSystemInfoSync()` call the fixture itself makes from App.onLaunch and the entry page's own onLoad (see fixtures/orientation-app-landscape/app.js and pages/entry/entry.js) — polling the geometry via a later snapshot would pass even if the seed were broken and the page only turned landscape after a subsequent PAGE_RESIZE corrected it.
+ * 2. A page-level `pageOrientation` overrides the app-level one, in both
+ * directions: a page-level 'auto' escapes an app-level fixed orientation and follows the device again; a page-level fixed value overrides an app-level fixed value with its own.
+ * 3. The simulated device's own orientation is never written back to by a
+ * mini-app's forced orientation, at the APP level exactly as at the page level: closing the session and reopening it must show the device's real, untouched orientation.
+ * Because the entry page here is itself fixed by app-level config, the exit-recovery assertion routes through an 'auto' page instead of the entry page — only an 'auto' page can reveal what the device orientation actually is.
+ *
+ * Fixtures:
+ * - e2e/fixtures/orientation-app-landscape — app.json's window carries
+ * `pageOrientation: "landscape"`. pages/entry (no page-level config, inherits the app's landscape), pages/autopage ('auto', escapes the app-level landscape and follows the device), pages/portraitpage ('portrait', overrides the app-level landscape).
+ * - e2e/fixtures/landscape-app — a wholly UNCONFIGURED app (no
+ * window.pageOrientation) whose home page also carries no page-level config, so both levels resolve to the DEFAULT_PAGE_ORIENTATION fallback ('portrait').
+ * Reused as-is (not modified) to cover the all-default corner of the config matrix combined with exit-recovery.
+ *
+ * Driving mechanism: `__diminaE2eHooks.rotateDevice()`, not `setDevice()` — see native-host-orientation-config.spec.ts's module doc comment for why `setDevice`'s raw host-env push cannot stand in for a real rotation. `wx.navigateTo`/`navigateBack` go through `callWxMethod`, matching native-host-orientation-config.spec.ts; no fixture button taps are needed.
+ *
+ * Geometry is read from the SERVICE host's own `wx.getSystemInfoSync()` (`readServiceSystemInfo`) — the authoritative channel the mini-app's own code observes, exactly as the sibling orientation specs do.
+ */
+import { test, expect, _electron, type ElectronApplication, type Page as PwPage } from '@playwright/test'
+import path from 'path'
+import fs from 'fs'
+import { fileURLToPath } from 'url'
+import {
+ openProject,
+ waitForSimulatorWebview,
+ closeProject,
+ pollUntil,
+ evalInSimulator,
+ getCurrentPage,
+ getPageData,
+ waitForServicePageReady,
+ callWxMethod,
+} from './helpers'
+import type { NativeDeviceInfo } from '@dimina-kit/electron-runtime'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const APP_LANDSCAPE_DIR = path.resolve(__dirname, 'fixtures', 'orientation-app-landscape')
+const DEFAULT_APP_DIR = path.resolve(__dirname, 'fixtures', 'landscape-app')
+
+const ENTRY_ROUTE = 'pages/entry/entry'
+const AUTO_ROUTE = 'pages/autopage/autopage'
+const PORTRAIT_ROUTE = 'pages/portraitpage/portraitpage'
+const DEFAULT_AUTO_ROUTE = 'pages/auto-page/auto-page'
+
+let electronApp: ElectronApplication
+let mainWindow: PwPage
+
+// ── Device rotation (see the module doc comment for why this goes through rotateDevice, not setDevice) ─────────────────────────────────────────
+
+function device(orientation: 'portrait' | 'landscape'): NativeDeviceInfo {
+ return {
+ brand: 'Apple',
+ model: 'iPhone SE',
+ system: 'iOS 15.0',
+ platform: 'ios',
+ pixelRatio: 2,
+ screenWidth: 375,
+ screenHeight: 667,
+ statusBarHeight: 20,
+ notchType: 'none',
+ safeAreaInsets: { top: 20, right: 0, bottom: 0, left: 0 },
+ deviceOrientation: orientation,
+ }
+}
+
+async function rotateDevice(app: ElectronApplication, orientation: 'portrait' | 'landscape'): Promise {
+ await app.evaluate((_electron, d) => {
+ const hooks = (globalThis as Record).__diminaE2eHooks as { rotateDevice: (device: unknown) => void }
+ hooks.rotateDevice(d)
+ }, device(orientation))
+}
+
+// ── Geometry (service-host wx.getSystemInfoSync()) ────────────────────
+
+interface ReportedInfo {
+ windowWidth?: number
+ windowHeight?: number
+}
+
+async function readServiceSystemInfo(app: ElectronApplication): Promise {
+ return pollUntil(
+ () => app.evaluate(async ({ webContents }) => {
+ const svc = webContents.getAllWebContents().find(
+ (wc) => !wc.isDestroyed() && wc.getURL().includes('/service-host/service.html'),
+ )
+ if (!svc) throw new Error('service.html not found')
+ return svc.executeJavaScript(`(() => {
+ const w = globalThis.wx
+ if (!w || typeof w.getSystemInfoSync !== 'function') throw new Error('wx.getSystemInfoSync missing')
+ const i = w.getSystemInfoSync()
+ return { windowWidth: i.windowWidth, windowHeight: i.windowHeight }
+ })()`)
+ }).catch(() => ({}) as ReportedInfo),
+ (info) =>
+ typeof info.windowWidth === 'number' && Number.isFinite(info.windowWidth)
+ && typeof info.windowHeight === 'number' && Number.isFinite(info.windowHeight),
+ 20_000,
+ 400,
+ )
+}
+
+/** Poll until the reported viewport matches `expected` — orientation changes
+ * are asynchronous, so a single snapshot right after a triggering action can race a not-yet-landed update. */
+async function waitForOrientation(app: ElectronApplication, expected: 'portrait' | 'landscape'): Promise {
+ return pollUntil(
+ () => readServiceSystemInfo(app),
+ (info) => {
+ const w = info.windowWidth ?? -1
+ const h = info.windowHeight ?? -1
+ return expected === 'landscape' ? w > h : w < h
+ },
+ 15_000,
+ 400,
+ )
+}
+
+async function waitForRoute(app: ElectronApplication, appId: string, route: string): Promise {
+ await pollUntil(
+ () => getCurrentPage(app, appId).catch(() => null),
+ (r) => !!r && typeof r.path === 'string' && r.path.includes(route),
+ 15_000,
+ 500,
+ )
+}
+
+// ── Cold-start seed snapshot (fixtures/orientation-app-landscape's entry page) ──
+
+interface EntryLoadSnapshot {
+ onLoadWindowWidth?: number
+ onLoadWindowHeight?: number
+ onLaunchWindowWidth?: number
+ onLaunchWindowHeight?: number
+}
+
+async function getEntryLoadData(app: ElectronApplication, appId: string): Promise {
+ const data = await getPageData(app, appId)
+ return (data && typeof data === 'object') ? (data as EntryLoadSnapshot) : {}
+}
+
+/**
+ * Poll only until the entry page's onLoad/onLaunch snapshot has been WRITTEN — those fields are each a synchronous read taken exactly once (App.onLaunch, Page.onLoad) and never change again afterwards, so polling their VALUE the way `waitForOrientation` does would defeat the point: a broken cold-start seed that only self-corrects after a later PAGE_RESIZE would still eventually satisfy a value-based poll.
+ * Only the snapshot's ARRIVAL is legitimately async (the page needs to instantiate first); the comparison itself must run on the one value that was captured.
+ */
+async function waitForEntryLoadSnapshot(app: ElectronApplication, appId: string): Promise {
+ return pollUntil(
+ () => getEntryLoadData(app, appId),
+ (d) => typeof d.onLoadWindowWidth === 'number' && typeof d.onLaunchWindowWidth === 'number',
+ 15_000,
+ 300,
+ )
+}
+
+test.describe('native-host app-level window.pageOrientation resolution + exit-recovery e2e', () => {
+ test.describe.configure({ mode: 'serial' })
+ test.setTimeout(240_000)
+
+ test.beforeAll(async () => {
+ test.setTimeout(180_000)
+ const appPath = path.resolve(__dirname, 'electron-entry.js')
+ const userDataDir = path.resolve(
+ process.env.DIMINA_DEVTOOLS_DATA_DIR
+ ?? path.resolve(__dirname, '..', 'node_modules', '.cache', 'electron-runtime-e2e'),
+ 'userdata',
+ `nh-orientation-app-config-${process.pid}`,
+ )
+ fs.mkdirSync(userDataDir, { recursive: true })
+
+ electronApp = await _electron.launch({
+ args: [appPath, `--user-data-dir=${userDataDir}`],
+ env: { ...process.env, NODE_ENV: 'test', DIMINA_E2E_USER_DATA_DIR: userDataDir },
+ })
+
+ mainWindow = await electronApp.firstWindow()
+ await mainWindow.waitForLoadState('domcontentloaded')
+
+ await electronApp.evaluate(async ({ BrowserWindow }) => {
+ const win = BrowserWindow.getAllWindows()[0]
+ if (win && !win.isVisible()) {
+ await new Promise((resolve) => {
+ win.once('show', resolve)
+ setTimeout(resolve, 5000)
+ })
+ }
+ if (win) {
+ win.setPosition(-2000, -2000)
+ win.blur()
+ }
+ })
+ })
+
+ test.afterEach(async () => {
+ await closeProject(electronApp).catch(() => {})
+ })
+
+ test.afterAll(async () => {
+ await closeProject(electronApp).catch(() => {})
+ await electronApp?.close().catch(() => {})
+ })
+
+ async function openFixtureAndWait(dir: string): Promise {
+ const { appId } = await openProject(electronApp, dir)
+ await waitForSimulatorWebview(electronApp)
+ await pollUntil(
+ () => evalInSimulator(
+ electronApp,
+ `(() => !!document.querySelector('.device-shell-root'))()`,
+ ).catch(() => false),
+ (ok) => ok === true,
+ 25_000,
+ 300,
+ )
+ await pollUntil(
+ () => getCurrentPage(electronApp, appId).catch(() => null),
+ (r) => !!r && typeof r.path === 'string' && r.path.includes('pages/'),
+ 20_000,
+ 500,
+ )
+ await waitForServicePageReady(electronApp, appId)
+ return appId
+ }
+
+ /** Set the device before opening (persists into the session spawn), open, then re-sync the now-live session. */
+ async function openFixture(dir: string, initialOrientation: 'portrait' | 'landscape'): Promise {
+ await rotateDevice(electronApp, initialOrientation)
+ const appId = await openFixtureAndWait(dir)
+ await rotateDevice(electronApp, initialOrientation)
+ await new Promise((r) => setTimeout(r, 1000))
+ return appId
+ }
+
+ test('portrait device + app-level landscape: entry page renders landscape on the very first frame; exit and reopen leave the device portrait', async () => {
+ const appId = await openFixture(APP_LANDSCAPE_DIR, 'portrait')
+
+ // No polling on the VALUE here — see waitForEntryLoadSnapshot's doc comment.
+ // Both a page's own onLoad and App.onLaunch are synchronous calls that ran once, before this test could have observed any later PAGE_RESIZE correction.
+ const snapshot = await waitForEntryLoadSnapshot(electronApp, appId)
+ expect(
+ snapshot.onLoadWindowWidth!,
+ `entry's own synchronous wx.getSystemInfoSync() call in onLoad must already read landscape — the cold-start seed exists precisely so this call does not need a later PAGE_RESIZE to correct it (got ${snapshot.onLoadWindowWidth}x${snapshot.onLoadWindowHeight})`,
+ ).toBeGreaterThan(snapshot.onLoadWindowHeight!)
+ expect(
+ snapshot.onLaunchWindowWidth!,
+ `App.onLaunch's own (even earlier) synchronous read must also already be landscape (got ${snapshot.onLaunchWindowWidth}x${snapshot.onLaunchWindowHeight})`,
+ ).toBeGreaterThan(snapshot.onLaunchWindowHeight!)
+
+ await closeProject(electronApp)
+
+ // Reopen WITHOUT touching the device — the entry page's forced landscape must not have overwritten the device's real (portrait) orientation.
+ const reopenedAppId = await openFixtureAndWait(APP_LANDSCAPE_DIR)
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + AUTO_ROUTE }])
+ await waitForRoute(electronApp, reopenedAppId, AUTO_ROUTE)
+ const afterReopen = await waitForOrientation(electronApp, 'portrait')
+ expect(
+ afterReopen.windowHeight!,
+ 'the auto page must show the device is still portrait after exit — the entry page\'s forced landscape must not have been written back',
+ ).toBeGreaterThan(afterReopen.windowWidth!)
+ })
+
+ test('landscape device + app-level landscape: entry page renders landscape', async () => {
+ await openFixture(APP_LANDSCAPE_DIR, 'landscape')
+ const info = await waitForOrientation(electronApp, 'landscape')
+ expect(
+ info.windowWidth!,
+ `device and app-level config agree on landscape (got ${info.windowWidth}x${info.windowHeight})`,
+ ).toBeGreaterThan(info.windowHeight!)
+ })
+
+ test('landscape device + a wholly unconfigured app: home page renders portrait (the default fallback); exit and reopen leave the device landscape', async () => {
+ await openFixture(DEFAULT_APP_DIR, 'landscape')
+ const info = await waitForOrientation(electronApp, 'portrait')
+ expect(
+ info.windowHeight!,
+ `an app with no window.pageOrientation and a page with no pageOrientation of its own must resolve to the 'portrait' default, even under a landscape device (got ${info.windowWidth}x${info.windowHeight})`,
+ ).toBeGreaterThan(info.windowWidth!)
+
+ await closeProject(electronApp)
+
+ const reopenedAppId = await openFixtureAndWait(DEFAULT_APP_DIR)
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + DEFAULT_AUTO_ROUTE }])
+ await waitForRoute(electronApp, reopenedAppId, DEFAULT_AUTO_ROUTE)
+ const afterReopen = await waitForOrientation(electronApp, 'landscape')
+ expect(
+ afterReopen.windowWidth!,
+ 'the auto page must show the device is still landscape after exit — the home page\'s default-resolved portrait must not have been written back',
+ ).toBeGreaterThan(afterReopen.windowHeight!)
+ })
+
+ test('app-level landscape + page-level auto: navigating to the auto page under a portrait device shows portrait, escaping the app-level landscape', async () => {
+ const appId = await openFixture(APP_LANDSCAPE_DIR, 'portrait')
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + AUTO_ROUTE }])
+ await waitForRoute(electronApp, appId, AUTO_ROUTE)
+ const info = await waitForOrientation(electronApp, 'portrait')
+ expect(
+ info.windowHeight!,
+ `pageOrientation:'auto' must override the app-level 'landscape' and follow the (portrait) device (got ${info.windowWidth}x${info.windowHeight})`,
+ ).toBeGreaterThan(info.windowWidth!)
+ })
+
+ test('app-level landscape + page-level portrait: navigateTo flips portrait; navigateBack restores the entry page\'s own (app-inherited) landscape', async () => {
+ const appId = await openFixture(APP_LANDSCAPE_DIR, 'portrait')
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + PORTRAIT_ROUTE }])
+ await waitForRoute(electronApp, appId, PORTRAIT_ROUTE)
+ const during = await waitForOrientation(electronApp, 'portrait')
+ expect(
+ during.windowHeight!,
+ `pageOrientation:'portrait' must override the app-level 'landscape' (got ${during.windowWidth}x${during.windowHeight})`,
+ ).toBeGreaterThan(during.windowWidth!)
+
+ await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }])
+ await waitForRoute(electronApp, appId, ENTRY_ROUTE)
+ const after = await waitForOrientation(electronApp, 'landscape')
+ expect(
+ after.windowWidth!,
+ 'navigateBack must restore the entry page\'s own orientation (the app-inherited landscape), not stay stuck on the portrait page it left',
+ ).toBeGreaterThan(after.windowHeight!)
+ })
+})
diff --git a/packages/dimina-electron-runtime/e2e/native-host-orientation-config.spec.ts b/packages/dimina-electron-runtime/e2e/native-host-orientation-config.spec.ts
new file mode 100644
index 00000000..70642a7c
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/native-host-orientation-config.spec.ts
@@ -0,0 +1,321 @@
+/**
+ * E2E (native-host only): config-driven page orientation. `pageOrientation` on a page's own json (falling back to app.json's `window`) picks the page's fixed orientation ('portrait' default, 'landscape') or lets it follow the simulated device ('auto').
+ *
+ * Contract pinned here (plus route linkage — the subset this file was scoped to cover):
+ *
+ * 1. A `pageOrientation: 'landscape'` page reports windowWidth >
+ * windowHeight via `wx.getSystemInfoSync()` — the AUTHORITATIVE channel the mini-app's own code reads (see readServiceSystemInfo below) — and NEVER fires `Page.onResize` while the underlying device rotates under it (fixed orientation => no dispatch).
+ * 2. A `pageOrientation: 'auto'` page follows the device: rotating it
+ * fires exactly one `Page.onResize` with `{ size: { windowWidth, windowHeight }, deviceOrientation }`, and the reported dims are landscape.
+ * 3. Device-shell chrome: the status bar (`.device-statusbar`, see
+ * status-bar.tsx) is NOT RENDERED at all in landscape — device-shell.tsx gates it with `!embedded && statusBarHeight > 0 && `, so the DOM node itself is absent, not merely collapsed to zero height; the nav bar (`.nav-bar`, see navigation-bar.tsx) stays mounted and visible.
+ * 4. Route linkage: `navigateTo` into a fixed-landscape page flips the
+ * screen to landscape; `navigateBack` restores the orientation the previous (portrait) page had.
+ * 5. Second-interaction regression (repo CLAUDE.md "操作后的二次交互"):
+ * two CONSECUTIVE device rotations on an auto page each fire their own `onResize` (not swallowed/coalesced across rotations); entering the fixed-landscape page, going back, then entering it again reaches the correct orientation BOTH times.
+ *
+ * Observation channels:
+ * - GEOMETRY: `wx.getSystemInfoSync()` inside the service-host window
+ * (service.html) — the same channel native-host-device.spec.ts uses.
+ * It is the actual `wx` the mini-app's own code calls, so it is the most authoritative read available; it also carries `deviceOrientation` alongside window dims in one round trip. (The alternative DOM-measure channel — evalInSimulator against the device-shell ``'s own rect — was NOT used for geometry assertions: it measures the HOST's layout of the render-guest container, one layer further from what the mini-app's own JS actually observes, and this harness's session never resizes its WebContentsView bounds in response to orientation anyway — see native-host-device.spec.ts's doc comment on the same tradeoff.
+ * DOM measurement IS used below for the chrome checks (status bar / nav bar), where there is no `wx` API equivalent to read.)
+ * - EVENTS: `Page.onResize` — the fixture pages
+ * (fixtures/landscape-app/pages/{landscape-page,auto-page}) record call count + last argument into `data`, read back through `getPageData` (the same App-data-tap mechanism native-host-navigate-data.spec.ts uses for page data assertions).
+ * - CHROME: `evalInSimulator` DOM queries against the device-shell's own
+ * `.device-statusbar` / `.nav-bar` elements.
+ *
+ * Rotation-driving mechanism: `hooks.rotateDevice()`, added to electron-entry.js alongside the pre-existing `setDevice` hook.
+ * It broadcasts DEVICE_CHANGE to the mounted DeviceShell(s) ONLY, deliberately skipping the raw `service-host:host-env:update` push `setDevice` also does — see rotateDeviceHook's doc comment in electron-entry.js for why: that push is orientation-unaware and never gates through DeviceShell's own dispatch logic, so it cannot stand in for a real rotation in tests that assert onResize dispatch/gating.
+ */
+import { test, expect, useSharedProject } from './fixtures'
+import path from 'path'
+import { fileURLToPath } from 'url'
+import {
+ evalInSimulator,
+ pollUntil,
+ callWxMethod,
+ getCurrentPage,
+ getPageData,
+ waitForServicePageReady,
+} from './helpers'
+import type { ElectronApplication } from '@playwright/test'
+import type { NativeDeviceInfo } from '@dimina-kit/electron-runtime'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const FIXTURE_DIR = path.resolve(__dirname, 'fixtures', 'landscape-app')
+const APP_ID = 'devtools_landscape_fixture' // fixtures/landscape-app/project.config.json appid
+
+const HOME_ROUTE = 'pages/home/home'
+const LANDSCAPE_ROUTE = 'pages/landscape-page/landscape-page'
+const AUTO_ROUTE = 'pages/auto-page/auto-page'
+
+// ── Device rotation (see the module doc comment for why this bypasses `setDevice`'s raw host-env push and only exercises the DEVICE_CHANGE -> DeviceShell wire) ──
+
+/**
+ * screenWidth/screenHeight stay PORTRAIT-baseline (see NativeDeviceInfo's own doc comment) — only `deviceOrientation` changes between calls, mirroring exactly what a real rotate action changes on the device state.
+ */
+function device(orientation: 'portrait' | 'landscape'): NativeDeviceInfo {
+ return {
+ brand: 'Apple',
+ model: 'iPhone 14 Pro',
+ system: 'iOS 16.3',
+ platform: 'ios',
+ pixelRatio: 3,
+ screenWidth: 393,
+ screenHeight: 852,
+ statusBarHeight: 54,
+ notchType: 'dynamic-island',
+ safeAreaInsets: { top: 54, right: 0, bottom: 34, left: 0 },
+ deviceOrientation: orientation,
+ }
+}
+
+async function rotateDevice(app: ElectronApplication, orientation: 'portrait' | 'landscape'): Promise {
+ await app.evaluate((_electron, d) => {
+ const hooks = (globalThis as Record).__diminaE2eHooks as { rotateDevice: (device: unknown) => void }
+ hooks.rotateDevice(d)
+ }, device(orientation))
+}
+
+// ── Geometry (service-host wx.getSystemInfoSync(), see the module doc comment) ──
+
+interface ReportedInfo {
+ windowWidth?: number
+ windowHeight?: number
+ deviceOrientation?: string
+}
+
+async function readServiceSystemInfo(app: ElectronApplication): Promise {
+ return pollUntil(
+ () => app.evaluate(async ({ webContents }) => {
+ const svc = webContents.getAllWebContents().find(
+ (wc) => !wc.isDestroyed() && wc.getURL().includes('/service-host/service.html'),
+ )
+ if (!svc) throw new Error('service.html not found')
+ return svc.executeJavaScript(`(() => {
+ const w = globalThis.wx
+ if (!w || typeof w.getSystemInfoSync !== 'function') throw new Error('wx.getSystemInfoSync missing')
+ const i = w.getSystemInfoSync()
+ return { windowWidth: i.windowWidth, windowHeight: i.windowHeight, deviceOrientation: i.deviceOrientation }
+ })()`)
+ }).catch(() => ({}) as ReportedInfo),
+ (info) =>
+ typeof info.windowWidth === 'number' && Number.isFinite(info.windowWidth)
+ && typeof info.windowHeight === 'number' && Number.isFinite(info.windowHeight),
+ 20_000,
+ 400,
+ )
+}
+
+// ── Page data (onResize count/payload, see fixtures/landscape-app pages) ──
+
+async function getData(app: ElectronApplication): Promise> {
+ const data = await getPageData(app, APP_ID)
+ return (data && typeof data === 'object') ? (data as Record) : {}
+}
+
+async function waitForRoute(app: ElectronApplication, route: string): Promise {
+ await pollUntil(
+ () => getCurrentPage(app, APP_ID).catch(() => null),
+ (r) => !!r && typeof r.path === 'string' && r.path.includes(route),
+ 15_000,
+ 500,
+ )
+}
+
+// ── Chrome (device-shell status bar / nav bar DOM, see status-bar.tsx / navigation-bar.tsx) ──
+
+interface ChromeProbe {
+ present: boolean
+ height: number
+}
+
+async function probeStatusBar(app: ElectronApplication): Promise {
+ return evalInSimulator(app, `(() => {
+ const el = document.querySelector('.device-statusbar')
+ if (!el) return { present: false, height: 0 }
+ return { present: true, height: el.getBoundingClientRect().height }
+ })()`)
+}
+
+async function probeNavBar(app: ElectronApplication): Promise {
+ return evalInSimulator(app, `(() => {
+ const el = document.querySelector('.nav-bar')
+ if (!el) return { present: false, height: 0 }
+ return { present: true, height: el.getBoundingClientRect().height }
+ })()`)
+}
+
+test.describe('native-host config-driven page orientation', () => {
+ test.describe.configure({ mode: 'serial' })
+ test.setTimeout(180_000)
+
+ useSharedProject(test, FIXTURE_DIR)
+
+ // DeviceShell must be MOUNTED (its DEVICE_CHANGE listener subscribed) before anything below is meaningful — a rotation broadcast before mount is simply missed (no catch-up/replay for DEVICE_CHANGE).
+ // This has to run in `beforeAll` — BEFORE `beforeEach` below ever rotates the device — because Playwright always finishes every `beforeAll` before the first `beforeEach` of the first test, whereas a wait placed inside test 1's own body would run AFTER that first `beforeEach` already fired.
+ test.beforeAll(async ({ _workerElectron }) => {
+ await pollUntil(
+ () => evalInSimulator(
+ _workerElectron.app,
+ `(() => document.querySelectorAll('.device-shell__webview').length)()`,
+ ).catch(() => 0),
+ (n) => n >= 1,
+ 25_000,
+ 300,
+ )
+ // The route APIs resolve against the SERVICE host's own page stack, which is still empty for a few hundred ms after the render guest mounts.
+ // Navigating inside that window throws in the mini-app framework.
+ await waitForServicePageReady(_workerElectron.app)
+ })
+
+ // Device orientation is NOT part of useSharedProject's afterEach reset (that only unwinds the page stack + clears storage), so pin a known baseline before every test regardless of what the previous test rotated to.
+ test.beforeEach(async ({ electronApp }) => {
+ await rotateDevice(electronApp, 'portrait')
+ })
+
+ test('landscape-orientation page reports a landscape viewport and never fires onResize on device rotation', async ({ electronApp }) => {
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + LANDSCAPE_ROUTE }])
+ await waitForRoute(electronApp, LANDSCAPE_ROUTE)
+
+ const info = await readServiceSystemInfo(electronApp)
+ expect(
+ info.windowWidth!,
+ `fixed-landscape page should report windowWidth > windowHeight (got ${info.windowWidth}x${info.windowHeight})`,
+ ).toBeGreaterThan(info.windowHeight!)
+
+ // Rotate the underlying device twice (both directions) — a fixed page must never dispatch onResize regardless of device rotation.
+ await rotateDevice(electronApp, 'landscape')
+ await new Promise((r) => setTimeout(r, 1000))
+ await rotateDevice(electronApp, 'portrait')
+ await new Promise((r) => setTimeout(r, 1000))
+
+ const data = await getData(electronApp)
+ expect(
+ data.resizeCount ?? 0,
+ 'fixed-orientation page must never receive Page.onResize, even across two device rotations',
+ ).toBe(0)
+
+ await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }])
+ await waitForRoute(electronApp, HOME_ROUTE)
+ })
+
+ test('auto-orientation page follows device rotation and fires exactly one onResize', async ({ electronApp }) => {
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + AUTO_ROUTE }])
+ await waitForRoute(electronApp, AUTO_ROUTE)
+
+ const before = await getData(electronApp)
+ expect(before.resizeCount ?? 0, 'auto page should have received no resize calls before any rotation').toBe(0)
+
+ await rotateDevice(electronApp, 'landscape')
+
+ const after = await pollUntil(
+ () => getData(electronApp),
+ (d) => Number(d.resizeCount ?? 0) > 0,
+ 15_000,
+ 500,
+ )
+ expect(after.resizeCount, 'one rotation should dispatch exactly one onResize (coalesced, not doubled)').toBe(1)
+
+ const last = after.lastResize as { size?: { windowWidth?: number; windowHeight?: number }; deviceOrientation?: string } | null
+ expect(last?.deviceOrientation, 'onResize payload should report the new device orientation').toBe('landscape')
+ expect(
+ last?.size?.windowWidth,
+ `onResize size should be landscape (got ${JSON.stringify(last?.size)})`,
+ ).toBeGreaterThan(last?.size?.windowHeight ?? Number.POSITIVE_INFINITY)
+
+ await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }])
+ await waitForRoute(electronApp, HOME_ROUTE)
+ })
+
+ test('landscape hides the status bar chrome but keeps the nav bar', async ({ electronApp }) => {
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + LANDSCAPE_ROUTE }])
+ await waitForRoute(electronApp, LANDSCAPE_ROUTE)
+
+ const statusBar = await probeStatusBar(electronApp)
+ // device-shell.tsx renders `` behind `statusBarHeight > 0` — in landscape (phone) that's false, so the `.device-statusbar` node is entirely absent, not merely collapsed to height 0.
+ expect(
+ statusBar.present,
+ `phone landscape must not render the status bar node at all — got present=${statusBar.present} height=${statusBar.height}`,
+ ).toBe(false)
+
+ const navBar = await probeNavBar(electronApp)
+ expect(navBar.present, 'nav bar must stay mounted in landscape (nav bar height does not change with orientation)').toBe(true)
+ expect(navBar.height, 'nav bar must keep a non-zero rendered height in landscape').toBeGreaterThan(0)
+
+ await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }])
+ await waitForRoute(electronApp, HOME_ROUTE)
+ })
+
+ test('navigateTo a landscape page flips the screen; navigateBack restores portrait', async ({ electronApp }) => {
+ const beforeInfo = await readServiceSystemInfo(electronApp)
+ expect(
+ beforeInfo.windowHeight!,
+ `home page (default portrait) should start taller than wide (got ${beforeInfo.windowWidth}x${beforeInfo.windowHeight})`,
+ ).toBeGreaterThan(beforeInfo.windowWidth!)
+
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + LANDSCAPE_ROUTE }])
+ await waitForRoute(electronApp, LANDSCAPE_ROUTE)
+ const duringInfo = await readServiceSystemInfo(electronApp)
+ expect(duringInfo.windowWidth!, 'navigateTo a fixed-landscape page should flip the screen to landscape').toBeGreaterThan(duringInfo.windowHeight!)
+
+ await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }])
+ await waitForRoute(electronApp, HOME_ROUTE)
+ const afterInfo = await readServiceSystemInfo(electronApp)
+ expect(
+ afterInfo.windowHeight!,
+ 'navigateBack should restore the portrait viewport the entry page had before routing away',
+ ).toBeGreaterThan(afterInfo.windowWidth!)
+ })
+
+ test('two consecutive rotations on an auto page each fire their own onResize', async ({ electronApp }) => {
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + AUTO_ROUTE }])
+ await waitForRoute(electronApp, AUTO_ROUTE)
+
+ await rotateDevice(electronApp, 'landscape')
+ const first = await pollUntil(
+ () => getData(electronApp),
+ (d) => Number(d.resizeCount ?? 0) >= 1,
+ 15_000,
+ 500,
+ )
+ expect(first.resizeCount, 'first rotation should fire the first onResize').toBe(1)
+
+ await rotateDevice(electronApp, 'portrait')
+ const second = await pollUntil(
+ () => getData(electronApp),
+ (d) => Number(d.resizeCount ?? 0) >= 2,
+ 15_000,
+ 500,
+ )
+ expect(second.resizeCount, 'a second, consecutive rotation must fire a second onResize — not be swallowed or coalesced across rotations').toBe(2)
+ const last = second.lastResize as { deviceOrientation?: string } | null
+ expect(last?.deviceOrientation, 'the second onResize payload should report the second rotation\'s orientation').toBe('portrait')
+
+ await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }])
+ await waitForRoute(electronApp, HOME_ROUTE)
+ })
+
+ test('enter the landscape page, go back, and enter it again — geometry is correct both times', async ({ electronApp }) => {
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + LANDSCAPE_ROUTE }])
+ await waitForRoute(electronApp, LANDSCAPE_ROUTE)
+ const first = await readServiceSystemInfo(electronApp)
+ expect(first.windowWidth!, 'first entry into the landscape page should flip landscape').toBeGreaterThan(first.windowHeight!)
+
+ await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }])
+ await waitForRoute(electronApp, HOME_ROUTE)
+ const backHome = await readServiceSystemInfo(electronApp)
+ expect(backHome.windowHeight!, 'back on the entry page should restore portrait').toBeGreaterThan(backHome.windowWidth!)
+
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + LANDSCAPE_ROUTE }])
+ await waitForRoute(electronApp, LANDSCAPE_ROUTE)
+ const second = await readServiceSystemInfo(electronApp)
+ expect(
+ second.windowWidth!,
+ 're-entering the landscape page a second time should flip landscape again (not get stuck on the first exit\'s restored portrait)',
+ ).toBeGreaterThan(second.windowHeight!)
+
+ await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }])
+ await waitForRoute(electronApp, HOME_ROUTE)
+ })
+})
diff --git a/packages/dimina-electron-runtime/e2e/native-host-orientation-stack.spec.ts b/packages/dimina-electron-runtime/e2e/native-host-orientation-stack.spec.ts
new file mode 100644
index 00000000..894ee5df
--- /dev/null
+++ b/packages/dimina-electron-runtime/e2e/native-host-orientation-stack.spec.ts
@@ -0,0 +1,299 @@
+/**
+ * E2E (native-host only): page-stack orientation resolution across route entries other than plain navigateTo/navigateBack(1) (already covered by native-host-orientation-config.spec.ts), device rotation while a fixed-orientation page is on screen, and rapid re-entrant navigation.
+ *
+ * Contract pinned here (see shared/page-orientation.ts and DeviceShell's OrientationController, which is the single authority every route entry funnels through):
+ *
+ * 1. Each page keeps its OWN `PageOrientationState` for as long as it stays
+ * mounted (visible or cached under a hidden stack entry).
+ * Backgrounding a page never touches its state; bringing it back to the foreground — through navigateBack at ANY delta, redirectTo, or reLaunch — recomputes its effective orientation fresh from that page's own config, never from whatever page was in between.
+ * 2. `redirectTo`/`reLaunch` replace page-stack entries outright: the
+ * pages they discard are gone, along with any orientation they were showing — there is nothing left to "restore" back to.
+ * 3. An 'auto' page's effective orientation is a live function of the
+ * CURRENT device orientation, recomputed on every visit — including a re-visit via navigateBack past a fixed-orientation page that forced a different orientation while it was on top. "Restore what a page showed on the way in" is a fixed-orientation-only illusion; an 'auto' page never has a fixed value to restore to.
+ * 4. Back-to-back route calls (navigateTo immediately followed by
+ * navigateBack, issued before the entered page's own orientation/ geometry round trip necessarily lands — see the last test's own doc comment for what that leaves in flight) must still leave the page stack and the displayed orientation in a consistent end state — not stuck on whichever page's geometry happened to be mid-transition.
+ *
+ * Fixture: e2e/fixtures/orientation-app-landscape — app.json's window is 'landscape'. pages/entry (root, inherits landscape), pages/autopage ('auto', follows the device), pages/portraitpage ('portrait', fixed), pages/mid (no page-level config, inherits landscape same as entry — used as the middle layer of a three-deep stack so a page under app-level inheritance, not just an explicit config, is also exercised mid-stack).
+ *
+ * Driving mechanism: `__diminaE2eHooks.rotateDevice()`, not `setDevice()` — see native-host-orientation-config.spec.ts's module doc comment.
+ * Geometry is read from the SERVICE host's own `wx.getSystemInfoSync()`, the authoritative channel the mini-app's own code observes.
+ */
+import { test, expect, useSharedProject } from './fixtures'
+import path from 'path'
+import { fileURLToPath } from 'url'
+import {
+ pollUntil,
+ callWxMethod,
+ getCurrentPage,
+ getPageStack,
+ waitForServicePageReady,
+} from './helpers'
+import type { ElectronApplication } from '@playwright/test'
+import type { NativeDeviceInfo } from '@dimina-kit/electron-runtime'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const FIXTURE_DIR = path.resolve(__dirname, 'fixtures', 'orientation-app-landscape')
+const APP_ID = 'devtools_orientation_app_landscape_fixture' // fixtures/orientation-app-landscape/project.config.json appid
+
+const ENTRY_ROUTE = 'pages/entry/entry'
+const AUTO_ROUTE = 'pages/autopage/autopage'
+const PORTRAIT_ROUTE = 'pages/portraitpage/portraitpage'
+const MID_ROUTE = 'pages/mid/mid'
+
+// ── Device rotation (see the module doc comment) ──────────────────────
+
+function device(orientation: 'portrait' | 'landscape'): NativeDeviceInfo {
+ return {
+ brand: 'Apple',
+ model: 'iPhone 14 Pro',
+ system: 'iOS 16.3',
+ platform: 'ios',
+ pixelRatio: 3,
+ screenWidth: 393,
+ screenHeight: 852,
+ statusBarHeight: 54,
+ notchType: 'dynamic-island',
+ safeAreaInsets: { top: 54, right: 0, bottom: 34, left: 0 },
+ deviceOrientation: orientation,
+ }
+}
+
+async function rotateDevice(app: ElectronApplication, orientation: 'portrait' | 'landscape'): Promise {
+ await app.evaluate((_electron, d) => {
+ const hooks = (globalThis as Record).__diminaE2eHooks as { rotateDevice: (device: unknown) => void }
+ hooks.rotateDevice(d)
+ }, device(orientation))
+}
+
+// ── Geometry (service-host wx.getSystemInfoSync()) ────────────────────
+
+interface ReportedInfo {
+ windowWidth?: number
+ windowHeight?: number
+}
+
+async function readServiceSystemInfo(app: ElectronApplication): Promise {
+ return pollUntil(
+ () => app.evaluate(async ({ webContents }) => {
+ const svc = webContents.getAllWebContents().find(
+ (wc) => !wc.isDestroyed() && wc.getURL().includes('/service-host/service.html'),
+ )
+ if (!svc) throw new Error('service.html not found')
+ return svc.executeJavaScript(`(() => {
+ const w = globalThis.wx
+ if (!w || typeof w.getSystemInfoSync !== 'function') throw new Error('wx.getSystemInfoSync missing')
+ const i = w.getSystemInfoSync()
+ return { windowWidth: i.windowWidth, windowHeight: i.windowHeight }
+ })()`)
+ }).catch(() => ({}) as ReportedInfo),
+ (info) =>
+ typeof info.windowWidth === 'number' && Number.isFinite(info.windowWidth)
+ && typeof info.windowHeight === 'number' && Number.isFinite(info.windowHeight),
+ 20_000,
+ 400,
+ )
+}
+
+/** Poll until the reported viewport matches `expected` — orientation changes
+ * are asynchronous, so a single snapshot right after a triggering action can race a not-yet-landed update. */
+async function waitForOrientation(app: ElectronApplication, expected: 'portrait' | 'landscape'): Promise {
+ return pollUntil(
+ () => readServiceSystemInfo(app),
+ (info) => {
+ const w = info.windowWidth ?? -1
+ const h = info.windowHeight ?? -1
+ return expected === 'landscape' ? w > h : w < h
+ },
+ 15_000,
+ 400,
+ )
+}
+
+async function waitForRoute(app: ElectronApplication, route: string): Promise {
+ await pollUntil(
+ () => getCurrentPage(app, APP_ID).catch(() => null),
+ (r) => !!r && typeof r.path === 'string' && r.path.includes(route),
+ 15_000,
+ 500,
+ )
+}
+
+/**
+ * The stack main reports, once it has settled.
+ *
+ * Main drops its stored stack the moment a page closes and the shell republishes it from its next commit (see `disposePageSession` and the `notifyPageStack` effect), so a single snapshot taken right after a route entry that discards pages can land in that window and read empty.
+ */
+async function waitForPageStack(app: ElectronApplication): Promise {
+ const stack = await pollUntil(
+ () => getPageStack(app, APP_ID).catch(() => []),
+ (entries) => entries.length > 0,
+ 15_000,
+ 300,
+ )
+ return stack.map((e) => e.path)
+}
+
+test.describe('native-host page-stack orientation across route entries and rapid navigation', () => {
+ test.describe.configure({ mode: 'serial' })
+ test.setTimeout(180_000)
+
+ useSharedProject(test, FIXTURE_DIR)
+
+ test.beforeAll(async ({ _workerElectron }) => {
+ await waitForServicePageReady(_workerElectron.app, APP_ID)
+ })
+
+ // Device orientation is not part of useSharedProject's afterEach reset, so pin a known baseline before every test regardless of what the previous test rotated to.
+ test.beforeEach(async ({ electronApp }) => {
+ await rotateDevice(electronApp, 'portrait')
+ })
+
+ test('navigateBack({delta:2}) across a three-layer, cross-orientation stack lands on the bottom page\'s own orientation', async ({ electronApp }) => {
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + PORTRAIT_ROUTE }])
+ await waitForRoute(electronApp, PORTRAIT_ROUTE)
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + MID_ROUTE }])
+ await waitForRoute(electronApp, MID_ROUTE)
+ const beforeBack = await waitForOrientation(electronApp, 'landscape')
+ expect(beforeBack.windowWidth!, 'mid (inherited landscape) must be showing landscape before the multi-pop').toBeGreaterThan(beforeBack.windowHeight!)
+
+ await callWxMethod(electronApp, 'navigateBack', [{ delta: 2 }])
+ await waitForRoute(electronApp, ENTRY_ROUTE)
+ const stack = await waitForPageStack(electronApp)
+ expect(stack.join(','), 'popping 2 must discard both portraitpage and mid, leaving only entry').toContain(ENTRY_ROUTE)
+ expect(stack.length, 'no leftover intermediate pages after a multi-level pop').toBe(1)
+
+ const after = await waitForOrientation(electronApp, 'landscape')
+ expect(
+ after.windowWidth!,
+ 'landing 2 levels back must resolve to the LANDING page\'s own orientation (entry\'s inherited landscape), not the portrait page it skipped over',
+ ).toBeGreaterThan(after.windowHeight!)
+ })
+
+ test('redirectTo across orientations replaces the stack top; there is no earlier page left to restore', async ({ electronApp }) => {
+ await callWxMethod(electronApp, 'redirectTo', [{ url: '/' + PORTRAIT_ROUTE }])
+ await waitForRoute(electronApp, PORTRAIT_ROUTE)
+ const info = await waitForOrientation(electronApp, 'portrait')
+ expect(info.windowHeight!, 'redirectTo must show the new page\'s own orientation').toBeGreaterThan(info.windowWidth!)
+
+ const stack = await waitForPageStack(electronApp)
+ expect(stack.length, 'redirectTo replaces the page it was called from — the landscape entry page it replaced is gone, not just hidden').toBe(1)
+ expect(stack[0]).toContain(PORTRAIT_ROUTE)
+ })
+
+ test('reLaunch across orientations clears the whole stack to the new root page\'s own orientation', async ({ electronApp }) => {
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + PORTRAIT_ROUTE }])
+ await waitForRoute(electronApp, PORTRAIT_ROUTE)
+
+ await callWxMethod(electronApp, 'reLaunch', [{ url: '/' + MID_ROUTE }])
+ await waitForRoute(electronApp, MID_ROUTE)
+ const stack = await waitForPageStack(electronApp)
+ expect(stack.length, 'reLaunch clears the entire prior stack (entry + portraitpage), leaving only the new root').toBe(1)
+ expect(stack[0]).toContain(MID_ROUTE)
+
+ const info = await waitForOrientation(electronApp, 'landscape')
+ expect(info.windowWidth!, 'the reLaunch target\'s own (inherited landscape) orientation must apply').toBeGreaterThan(info.windowHeight!)
+ })
+
+ test('a three-layer stack alternating landscape/portrait/landscape resolves each layer correctly on both the way in and the way back out', async ({ electronApp }) => {
+ const entry = await waitForOrientation(electronApp, 'landscape')
+ expect(entry.windowWidth!, 'layer 1 (entry): landscape').toBeGreaterThan(entry.windowHeight!)
+
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + PORTRAIT_ROUTE }])
+ await waitForRoute(electronApp, PORTRAIT_ROUTE)
+ const layer2In = await waitForOrientation(electronApp, 'portrait')
+ expect(layer2In.windowHeight!, 'layer 2 (portraitpage): portrait, entering').toBeGreaterThan(layer2In.windowWidth!)
+
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + MID_ROUTE }])
+ await waitForRoute(electronApp, MID_ROUTE)
+ const layer3In = await waitForOrientation(electronApp, 'landscape')
+ expect(layer3In.windowWidth!, 'layer 3 (mid): landscape, entering').toBeGreaterThan(layer3In.windowHeight!)
+
+ await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }])
+ await waitForRoute(electronApp, PORTRAIT_ROUTE)
+ const layer2Out = await waitForOrientation(electronApp, 'portrait')
+ expect(layer2Out.windowHeight!, 'layer 2 (portraitpage): portrait, on the way back out').toBeGreaterThan(layer2Out.windowWidth!)
+
+ await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }])
+ await waitForRoute(electronApp, ENTRY_ROUTE)
+ const layer1Out = await waitForOrientation(electronApp, 'landscape')
+ expect(layer1Out.windowWidth!, 'layer 1 (entry): landscape, on the way back out').toBeGreaterThan(layer1Out.windowHeight!)
+ })
+
+ test('rotating the device on an auto page (the rotate control\'s own reachable path), then visiting a fixed-portrait page and back, leaves the auto page following the current device — not the fixed page\'s orientation it just left', async ({ electronApp }) => {
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + AUTO_ROUTE }])
+ await waitForRoute(electronApp, AUTO_ROUTE)
+ const baseline = await waitForOrientation(electronApp, 'portrait')
+ expect(baseline.windowHeight!, 'auto page starts portrait, tracking the portrait device').toBeGreaterThan(baseline.windowWidth!)
+
+ // The rotate control is enabled here: an 'auto' page's canRotate is true (orientation-controller.ts's canRotateFor), so this rotation is one a real user could trigger through the UI.
+ await rotateDevice(electronApp, 'landscape')
+ const rotated = await waitForOrientation(electronApp, 'landscape')
+ expect(rotated.windowWidth!, 'the auto page follows the rotation to landscape').toBeGreaterThan(rotated.windowHeight!)
+
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + PORTRAIT_ROUTE }])
+ await waitForRoute(electronApp, PORTRAIT_ROUTE)
+ const onFixed = await waitForOrientation(electronApp, 'portrait')
+ expect(onFixed.windowHeight!, 'the fixed portrait page forces portrait regardless of the (landscape) device').toBeGreaterThan(onFixed.windowWidth!)
+
+ await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }])
+ await waitForRoute(electronApp, AUTO_ROUTE)
+ const back = await waitForOrientation(electronApp, 'landscape')
+ expect(
+ back.windowWidth!,
+ 'back on the auto page, it must show landscape (the current device) — not the portrait it just displayed on the fixed page it left',
+ ).toBeGreaterThan(back.windowHeight!)
+ })
+
+ test('the device orientation changing while a fixed-orientation page is on screen (the rotate control is disabled for it, but the device state itself can still move — e.g. a device-model switch) is picked up by the auto page underneath once control returns to it', async ({ electronApp }) => {
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + AUTO_ROUTE }])
+ await waitForRoute(electronApp, AUTO_ROUTE)
+ const baseline = await waitForOrientation(electronApp, 'portrait')
+ expect(baseline.windowHeight!, 'auto page starts portrait, tracking the portrait device').toBeGreaterThan(baseline.windowWidth!)
+
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + MID_ROUTE }])
+ await waitForRoute(electronApp, MID_ROUTE)
+ const onFixed = await waitForOrientation(electronApp, 'landscape')
+ expect(onFixed.windowWidth!, 'mid (inherited landscape) is fixed regardless of the device').toBeGreaterThan(onFixed.windowHeight!)
+
+ // mid's canRotate is false — the rotate control is disabled while it is the top page — but the device's own orientation is still driven here through the same hook the rest of this suite uses, standing in for a non-rotate-control source of a device change (e.g. switching the simulated device model in the toolbar).
+ await rotateDevice(electronApp, 'landscape')
+ await new Promise((r) => setTimeout(r, 500))
+ const stillFixed = await readServiceSystemInfo(electronApp)
+ expect(stillFixed.windowWidth!, 'mid stays landscape — a fixed page never reacts to the device').toBeGreaterThan(stillFixed.windowHeight!)
+
+ await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }])
+ await waitForRoute(electronApp, AUTO_ROUTE)
+ const back = await waitForOrientation(electronApp, 'landscape')
+ expect(
+ back.windowWidth!,
+ 'the auto page must pick up the device\'s NEW orientation (landscape) on return — not the portrait it displayed before the fixed page took over',
+ ).toBeGreaterThan(back.windowHeight!)
+ })
+
+ test('navigateTo a landscape page immediately followed by navigateBack, before its own orientation round trip necessarily lands, ends in a consistent state on the originating page', async ({ electronApp }) => {
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + AUTO_ROUTE }])
+ await waitForRoute(electronApp, AUTO_ROUTE)
+ const baselineStack = await getPageStack(electronApp, APP_ID)
+
+ // callWxMethod's own await (runNativeHostNav → waitForActivePage in electron-entry.js) DOES wait for the active page to switch to the page just navigated to — so by the time the first call below resolves, mid IS the active page.
+ // What it does NOT wait for is mid's own orientation/geometry round trip: DeviceShell computes the effective orientation and relays it back over PAGE_RESIZE as a separate, uncoordinated step after the active-page switch.
+ // Firing navigateBack immediately, with no waitForRoute()/waitForOrientation() in between, races that in-flight PAGE_RESIZE against the pop — a late PAGE_RESIZE computed for mid must not land on (or get attributed to) whatever page is on top by the time it arrives.
+ await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + MID_ROUTE }])
+ await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }])
+
+ await waitForRoute(electronApp, AUTO_ROUTE)
+ const stack = await pollUntil(
+ () => getPageStack(electronApp, APP_ID),
+ (s) => s.length === baselineStack.length,
+ 15_000,
+ 400,
+ )
+ expect(stack.length, 'the push/pop pair must cancel out — no orphaned mid page left on the stack').toBe(baselineStack.length)
+
+ const info = await waitForOrientation(electronApp, 'portrait')
+ expect(
+ info.windowHeight!,
+ 'the final state must be the auto page\'s own (portrait) orientation — not stuck on mid\'s landscape mid-transition',
+ ).toBeGreaterThan(info.windowWidth!)
+ })
+})
diff --git a/packages/dimina-electron-runtime/e2e/native-host-page-stack.spec.ts b/packages/dimina-electron-runtime/e2e/native-host-page-stack.spec.ts
index d843cf9a..eb9d0d12 100644
--- a/packages/dimina-electron-runtime/e2e/native-host-page-stack.spec.ts
+++ b/packages/dimina-electron-runtime/e2e/native-host-page-stack.spec.ts
@@ -38,6 +38,7 @@ import {
getPageStack,
getCurrentPage,
callWxMethod,
+ waitForServicePageReady,
type PageStackEntry,
} from './helpers'
@@ -105,6 +106,9 @@ test.describe('native-host App.getPageStack tracks full in-app navigation stack'
25000,
300,
)
+ // A mounted render guest is not a navigable session: the guest's URL carries its pagePath from creation, while the route APIs resolve against the SERVICE host's own page stack, which is still empty for a few hundred more ms.
+ // Navigating inside that window throws in the mini-app framework.
+ await waitForServicePageReady(electronApp)
})
test.afterAll(async () => {
diff --git a/packages/dimina-electron-runtime/e2e/native-host-render.spec.ts b/packages/dimina-electron-runtime/e2e/native-host-render.spec.ts
index 8eb86cf2..68fbcaa8 100644
--- a/packages/dimina-electron-runtime/e2e/native-host-render.spec.ts
+++ b/packages/dimina-electron-runtime/e2e/native-host-render.spec.ts
@@ -36,6 +36,7 @@ import {
getPageData,
callWxMethod,
RENDER_GUEST_URL_MARKER,
+ waitForServicePageReady,
} from './helpers'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -82,6 +83,9 @@ test.describe('native-host render path e2e', () => {
await openProject(electronApp, FIXTURE_DIR)
await waitForSimulatorWebview(electronApp)
+ // The route APIs resolve against the SERVICE host's own page stack, which is still empty for a few hundred ms after the render guest mounts.
+ // Navigating inside that window throws in the mini-app framework.
+ await waitForServicePageReady(electronApp)
})
test.afterAll(async () => {
diff --git a/packages/dimina-electron-runtime/e2e/native-host-switchtab-rerender.spec.ts b/packages/dimina-electron-runtime/e2e/native-host-switchtab-rerender.spec.ts
index 81e54671..4ca4bcfe 100644
--- a/packages/dimina-electron-runtime/e2e/native-host-switchtab-rerender.spec.ts
+++ b/packages/dimina-electron-runtime/e2e/native-host-switchtab-rerender.spec.ts
@@ -17,6 +17,7 @@ import { fileURLToPath } from 'url'
import {
openProject, waitForSimulatorWebview, closeProject, pollUntil,
evalInSimulator, evalInWebContentsByUrl, getCurrentPage, callWxMethod,
+ waitForServicePageReady,
} from './helpers'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -76,6 +77,9 @@ test.describe('native-host switchTab keeps rendered content on return', () => {
await waitForSimulatorWebview(electronApp)
await pollUntil(() => evalInSimulator(electronApp,
`(() => document.querySelectorAll('.device-shell__webview').length)()`).catch(() => 0), (n) => n >= 1, 30000, 400)
+ // The route APIs resolve against the SERVICE host's own page stack, which is still empty for a few hundred ms after the render guest mounts.
+ // Navigating inside that window throws in the mini-app framework.
+ await waitForServicePageReady(electronApp)
await waitActive('pages/home/home')
})
test.afterAll(async () => { await closeProject(electronApp).catch(() => {}); await electronApp?.close().catch(() => {}) })
diff --git a/packages/dimina-electron-runtime/package.json b/packages/dimina-electron-runtime/package.json
index 5e5eef5e..1ae32d48 100644
--- a/packages/dimina-electron-runtime/package.json
+++ b/packages/dimina-electron-runtime/package.json
@@ -30,6 +30,10 @@
"types": "./dist/shared/bridge-channels.d.ts",
"default": "./dist/shared/bridge-channels.js"
},
+ "./shared/page-orientation": {
+ "types": "./dist/shared/page-orientation.d.ts",
+ "default": "./dist/shared/page-orientation.js"
+ },
"./shared/simulator-api-metadata": {
"types": "./dist/shared/simulator-api-metadata.d.ts",
"default": "./dist/shared/simulator-api-metadata.js"
diff --git a/packages/dimina-electron-runtime/src/main/ipc/bridge-router.ts b/packages/dimina-electron-runtime/src/main/ipc/bridge-router.ts
index 295c6dc8..123d7592 100644
--- a/packages/dimina-electron-runtime/src/main/ipc/bridge-router.ts
+++ b/packages/dimina-electron-runtime/src/main/ipc/bridge-router.ts
@@ -2,9 +2,21 @@ import { app, BrowserWindow, ipcMain, protocol, session as electronSession, webC
import type { IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
-import { BRIDGE_CHANNELS as C, SIMULATOR_EVENTS as E, deviceInfoToHostEnv } from '../../shared/bridge-channels.js'
+import { BRIDGE_CHANNELS as C, SERVICE_HOST_CHANNELS, SIMULATOR_EVENTS as E, deviceInfoToHostEnv } from '../../shared/bridge-channels.js'
+import { pageResizeHostEnv } from '../../shared/page-resize-host-env.js'
import type { NativeDeviceInfo, SyncStorageChange } from '../../shared/runtime-types.js'
import { apiCallWatchdogMs, isPersistentSimulatorApi } from '../../shared/simulator-api-metadata.js'
+import {
+ effectiveOrientation,
+ isPageOrientationConfig,
+ resolvePageOrientationState,
+ withPageWindowSize,
+} from '../../shared/page-orientation.js'
+import type {
+ Orientation,
+ PageResizePayload,
+} from '../../shared/page-orientation.js'
+import { routeContainerPage, stalePageApiErrMsg } from './container-routing.js'
import { resolveRuntimeAssetPaths } from '../utils/paths.js'
import { createSessionListenerBag } from './session-listener-bag.js'
import type { SessionListenerBag } from './session-listener-bag.js'
@@ -25,6 +37,7 @@ import type {
PageOpenResult,
PageStackEntry,
PageStackPayload,
+ SessionActivePayload,
PageWindowConfig,
RenderInvokePayload,
RenderPublishPayload,
@@ -74,6 +87,8 @@ import {
type AppLifecycleController,
type AppLifecycleEvent,
} from './app-lifecycle.js'
+import { createWindowResizeController, type WindowResizeController } from './window-resize.js'
+import type { PageClosedEvent, SessionOrientationEvent } from '../runtime-events.js'
// The compiled `logic.js` ships a RELATIVE `//# sourceMappingURL=logic.js.map`.
// `injectLogicBundle` loads it via `executeJavaScript`, which gives the injected
@@ -332,6 +347,11 @@ interface RouterState {
simulatorWcIdToAppSessionIds: Map>
/** renderWc → bridgeId. */
wcIdToBridgeId: Map
+ /**
+ * The app session the simulator declared as the one on screen (`SESSION_ACTIVE`), or null when nothing has claimed it.
+ * Only that session's `'session-orientation'` broadcasts are marked `active`, so a soft-reload session booting behind the visible one — and the outgoing one's later teardown — cannot move the renderer's panel geometry.
+ */
+ activeAppSessionId: string | null
/** requestId → pending API_CALL forwarded to a simulator window. */
pendingApiCalls: Map
/** Pre-warm pool for service-host windows; null when pooling is disabled. */
@@ -368,6 +388,11 @@ interface RouterState {
* appSessionId. Fired on main-window foreground/background and service errors.
*/
appLifecycle: AppLifecycleController
+ /**
+ * Per-session `wx.onWindowResize` listener registry (keep subscriptions — see `window-resize.ts`).
+ * Fired on every dispatchable PAGE_RESIZE.
+ */
+ windowResize: WindowResizeController
/** Main-process WebSocket transport. Uses Node net/tls through `ws`, never Chromium. */
nativeWebSocket: NativeWebSocketService
/**
@@ -379,6 +404,16 @@ interface RouterState {
* with ghost AppData tabs after a respawn.
*/
evictAppDataBridges: (ap: AppSession) => void
+ /**
+ * Broadcasts the orientation an app session currently forces (or `null` on teardown).
+ * Indirected through state (like `evictAppDataBridges`) so `disposeAppSession` — which only takes `state` — can reach `ctx.events` without threading `ctx` through the whole dispose chain.
+ */
+ emitSessionOrientation: (event: SessionOrientationEvent) => void
+ /**
+ * Announces one page's end, so main-process consumers holding per-page state keyed by `bridgeId` release it.
+ * Indirected through state for the same reason as `emitSessionOrientation`.
+ */
+ emitPageClosed: (event: PageClosedEvent) => void
}
/** Default timeout for a simulator-forwarded API call. */
@@ -450,6 +485,8 @@ export interface BridgeResourceCensus {
simulatorWcBindings: number
renderWcBindings: number
pendingApiCalls: number
+ /** Total `wx.onWindowResize` listener ids held across every live session. */
+ windowResizeListeners: number
/**
* `listenerCount('destroyed')` per unique live simulator wc (keyed by wc id).
* One teardown hook per live session — a count above the hosted-session count
@@ -599,6 +636,7 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
serviceWcIdToAppSessionId: new Map(),
simulatorWcIdToAppSessionIds: new Map(),
wcIdToBridgeId: new Map(),
+ activeAppSessionId: null,
pendingApiCalls: new Map(),
pool: null,
emitRenderEvent: () => {},
@@ -607,6 +645,7 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
connections: ctx.connections,
debugTap: createDebugTap({ enabled: resolveDebugTapEnabled() }),
appLifecycle: createAppLifecycleController(),
+ windowResize: createWindowResizeController(),
nativeWebSocket: createNativeWebSocketService({
idleTimeoutMs: socketIdleTimeoutMsFromEnv(),
}),
@@ -615,6 +654,8 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
ctx.events.emit('app-data-evict', { appId: ap.appId, bridgeId: page.bridgeId })
}
},
+ emitSessionOrientation: (event) => { ctx.events.emit('session-orientation', event) },
+ emitPageClosed: (event) => { ctx.events.emit('page-closed', event) },
}
ctx.registry.add(() => state.nativeWebSocket.dispose())
@@ -846,6 +887,7 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
simulatorWcBindings,
renderWcBindings: state.wcIdToBridgeId.size,
pendingApiCalls: state.pendingApiCalls.size,
+ windowResizeListeners: state.windowResize.count(),
simulatorDestroyedListeners,
}
},
@@ -934,6 +976,17 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
ipcMain.on(C.PAGE_STACK, onPageStack)
ctx.registry.add(() => { ipcMain.removeListener(C.PAGE_STACK, onPageStack) })
+ // DeviceShell → main: the session whose shell is on screen.
+ // Recorded rather than inferred — during a soft reload both sessions publish geometry, and "whoever reported last" would hand the screen to the invisible one.
+ const onSessionActive = (event: IpcMainEvent, payload: SessionActivePayload): void => {
+ const ap = state.appSessions.get(payload.appSessionId)
+ if (!ap) return
+ if (!senderBoundToSession(state, event.sender, ap)) return
+ state.activeAppSessionId = payload.appSessionId
+ }
+ ipcMain.on(C.SESSION_ACTIVE, onSessionActive)
+ ctx.registry.add(() => { ipcMain.removeListener(C.SESSION_ACTIVE, onSessionActive) })
+
ipcMain.handle(C.SPAWN, async (event, opts: SpawnRequest): Promise => {
return handleSpawn(state, ctx, event, opts)
})
@@ -964,6 +1017,14 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
ipcMain.on(C.PAGE_LIFECYCLE, onPageLifecycle)
ctx.registry.add(() => { ipcMain.removeListener(C.PAGE_LIFECYCLE, onPageLifecycle) })
+ // DeviceShell → main: orientation/size changed.
+ // DeviceShell owns the dispatch gate (payload.dispatchWindow / payload.dispatchPage) and the current device; main only mirrors the geometry into the session's host-env snapshot and, per channel, fires the service-side pageResize message and/or every registered wx.onWindowResize listener.
+ const onPageResize = (event: IpcMainEvent, payload: PageResizePayload): void => {
+ handlePageResize(state, event.sender, currentDevice, payload)
+ }
+ ipcMain.on(C.PAGE_RESIZE, onPageResize)
+ ctx.registry.add(() => { ipcMain.removeListener(C.PAGE_RESIZE, onPageResize) })
+
const onNavCallback = (event: IpcMainEvent, payload: NavCallbackPayload): void => {
handleNavCallback(state, event.sender, payload)
}
@@ -1007,7 +1068,7 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
tapIn(C.SERVICE_INVOKE, event.sender, payload)
const ap = appByWc(state, event.sender)
if (!ap) return
- const page = state.pageSessions.get(payload.bridgeId) ?? state.pageSessions.get(ap.appSessionId)
+ const page = serviceSenderPage(state, ap, payload.bridgeId)
if (!page) return
routeFromService(state, ap, page, payload.msg, ctx)
}
@@ -1177,19 +1238,6 @@ async function handleSpawn(
resourceServer = await startDiminaResourceServer(path.resolve(pkgRoot, root))
resourceBaseUrl = resourceServer.baseUrl
}
- // The selected device (renderer toolbar) is the authoritative source for the
- // logical dims a spawn must report. The simulator-supplied `hostEnvSnapshot`
- // is derived from the device baked into the simulator at BOOT time, so on a
- // RESPAWN after a live device change it still carries the boot device. Layer
- // the live `currentDevice` on top so every spawn/respawn reports the selected
- // device — matching what the live `SetDeviceInfo` HostEnvUpdate pushes to an
- // already-running service host. Pre-selection (null) → simulator snapshot wins.
- const selectedDevice = ctx.bridge?.getDevice?.() ?? null
- const hostEnv = makeHostEnv({
- ...opts.hostEnvSnapshot,
- ...(selectedDevice ? deviceInfoToHostEnv(selectedDevice) : {}),
- })
-
// app-config.json lives at `//app-config.json` on the dev
// server, or at the local server root for the fallback path.
const appConfig = await loadAppConfig(
@@ -1211,6 +1259,31 @@ async function handleSpawn(
const rootWindowConfig = resolvePageWindowConfig(appConfig, resolvedPagePath)
const isTab = isTabPage(appConfig, resolvedPagePath)
+ // The selected device (renderer toolbar) is the authoritative source for the logical dims a spawn must report.
+ // The simulator-supplied `hostEnvSnapshot` is derived from the device baked into the simulator at BOOT time, so on a RESPAWN after a live device change it still carries the boot device.
+ // Layer the live `currentDevice` on top so every spawn/respawn reports the selected device — matching what the live `SetDeviceInfo` HostEnvUpdate pushes to an already-running service host.
+ // Pre-selection (null) → simulator snapshot wins.
+ const selectedDevice = ctx.bridge?.getDevice?.() ?? null
+ // Seed with the ROOT PAGE's EFFECTIVE orientation, not the raw device orientation: DeviceShell mounts and sends the first PAGE_RESIZE only AFTER spawn resolves, so a page pinned to a non-auto pageOrientation must already report its effective geometry here — a synchronous wx.getSystemInfoSync() from App.onLaunch / the root page's onLoad would otherwise read the device's orientation instead of the page's.
+ // Reuses the SAME pure functions DeviceShell itself resolves orientation with (resolvePageOrientationState → effectiveOrientation) so main's seed and DeviceShell's later authoritative PAGE_RESIZE value can never disagree — one policy, two callers.
+ const bootDeviceOrientation: Orientation = selectedDevice?.deviceOrientation ?? 'portrait'
+ const rootOrientationState = resolvePageOrientationState(rootWindowConfig.pageOrientation)
+ const effectiveRootOrientation = effectiveOrientation(rootOrientationState, bootDeviceOrientation)
+ const orientedHostEnv = makeHostEnv({
+ ...opts.hostEnvSnapshot,
+ ...(selectedDevice
+ ? deviceInfoToHostEnv({ ...selectedDevice, deviceOrientation: effectiveRootOrientation })
+ : {}),
+ })
+ // `deviceInfoToHostEnv` only knows the device, so its `windowHeight` is the screen minus the status bar.
+ // The window a page actually gets is the screen minus the chrome that page keeps in flow, which is what the shell reports in its first PAGE_RESIZE.
+ // Apply the same `pageWindowSize` formula to the seed with the ROOT PAGE's chrome, so `App.onLaunch` and the root page's `onLoad` read the height the page ends up with instead of a taller one that silently shrinks on the first frame.
+ const hostEnv = withPageWindowSize(orientedHostEnv, {
+ navigationStyle: rootWindowConfig.navigationStyle,
+ isTab,
+ bottomInset: orientedHostEnv.safeAreaInsets?.bottom ?? 0,
+ })
+
// Acquire a pre-warmed service-host window when pooling is enabled; otherwise
// construct one fresh (default). A pooled/fallback window is warmed on
// about:blank and must be navigated to the spawn URL below; the fresh path
@@ -1522,6 +1595,59 @@ function handlePageLifecycle(state: RouterState, sender: WebContents, payload: P
})
}
+function handlePageResize(
+ state: RouterState,
+ sender: WebContents,
+ device: NativeDeviceInfo | null,
+ payload: PageResizePayload,
+): void {
+ const ap = state.appSessions.get(payload.appSessionId)
+ if (!ap) return
+ if (!senderBoundToSession(state, sender, ap)) return
+ // A late resize for a page that already closed (or was never a member of this session) must not resurrect stale geometry into a live session's hostEnv. `ap.pages` is the same closed-page ledger PAGE_CLOSE/disposePageSession maintain — no separate liveness tracking needed. bridgeIds are never reused (newBridgeId is timestamp+random), so this alone also rules out a stale payload landing on a DIFFERENT page that reused the same id.
+ if (!ap.pages.has(payload.bridgeId)) return
+ applyPageResize(state, ap, device, payload)
+}
+
+/**
+ * Refresh `ap.hostEnv` unconditionally and broadcast the session's forced orientation; the two resize channels then fire independently — `payload.dispatchPage` gates the service-side `pageResize` message (drives `Page.onResize` / component `resize`), `payload.dispatchWindow` gates every `wx.onWindowResize` listener registered for this session.
+ */
+function applyPageResize(
+ state: RouterState,
+ ap: AppSession,
+ device: NativeDeviceInfo | null,
+ payload: PageResizePayload,
+): void {
+ const patch = pageResizeHostEnv(payload, device)
+ ap.hostEnv = { ...ap.hostEnv, ...patch }
+ // Mirrors the web container's own theme-change push (miniApp.js): the service host's `core/host-env.js` already listens for this message type and replaces `snapshot.systemInfo` wholesale with the given object.
+ forwardToService(ap, { type: 'hostEnvUpdate', target: 'service', body: { systemInfo: ap.hostEnv } })
+ // The synchronous host APIs (`wx.getSystemInfoSync` and friends) read the spawn context's `hostEnvSnapshot`, which only this direct IPC channel patches — the bus message above feeds dimina's own store and never reaches it, so a page pinned to landscape would keep reporting portrait metrics.
+ if (!ap.serviceWc.isDestroyed()) {
+ ap.serviceWc.send(SERVICE_HOST_CHANNELS.HostEnvUpdate, patch)
+ }
+
+ state.emitSessionOrientation({
+ appSessionId: ap.appSessionId,
+ bridgeId: payload.bridgeId,
+ orientation: payload.deviceOrientation,
+ canRotate: payload.canRotate,
+ active: state.activeAppSessionId === ap.appSessionId,
+ })
+
+ if (payload.dispatchPage) {
+ forwardToService(ap, {
+ type: 'pageResize',
+ target: 'service',
+ body: { bridgeId: payload.bridgeId, size: payload.size, deviceOrientation: payload.deviceOrientation },
+ })
+ }
+ if (!payload.dispatchWindow) return
+ for (const id of state.windowResize.listeners(ap.appSessionId)) {
+ sendCallback(ap, id, { size: payload.size, deviceOrientation: payload.deviceOrientation })
+ }
+}
+
function handleNavCallback(state: RouterState, sender: WebContents, payload: NavCallbackPayload): void {
const ap = state.appSessions.get(payload.appSessionId)
if (!ap) return
@@ -1741,6 +1867,27 @@ function maybeSendResourceLoaded(ap: AppSession, page: PageSession): void {
// ── Message routing ──────────────────────────────────────────────────────────
+/**
+ * The page a service→container message is handled against before the message's own `body.bridgeId` refines it (`routeContainerPage`).
+ *
+ * The envelope's bridgeId is the service host's SPAWN id — the root page — and it never changes for the life of the window, so it stops resolving the moment navigation retires the launch page (`redirectTo`/`reLaunch`/"back to home" off it, which PAGE_CLOSE allows precisely because the session lives on).
+ * Dropping the message there would kill the session's whole service→container direction: every `wx.*` call the service makes travels this way, so the mini-app would go on running with every API call silently unanswered.
+ * The session's active page is the honest stand-in; a page the session still holds is better than none.
+ */
+function serviceSenderPage(
+ state: RouterState,
+ ap: AppSession,
+ senderBridgeId: string,
+): PageSession | undefined {
+ const named = state.pageSessions.get(senderBridgeId)
+ if (named) return named
+ const active = ap.activeBridgeId ? ap.pages.get(ap.activeBridgeId) : undefined
+ if (active) return active
+ let last: PageSession | undefined
+ for (const page of ap.pages.values()) last = page
+ return last
+}
+
function routeFromService(
state: RouterState,
ap: AppSession,
@@ -1757,8 +1904,19 @@ function routeFromService(
return
}
if (msg.target === 'container') {
- const page = pageFromMsg(state, ap, msg) ?? defaultPage
- handleContainerMsg(ap, page, msg, ctx, state)
+ const routing = routeContainerPage(ap.pages, readBridgeId(msg), defaultPage)
+ if (routing.staleBridgeId !== null && msg.type === 'invokeAPI') {
+ const body = msg.body as { name?: unknown, params?: unknown } | undefined
+ const name = String(body?.name ?? '')
+ const errMsg = stalePageApiErrMsg(name)
+ if (errMsg) {
+ // The calling page closed before its own call reached main.
+ // Answering the caller here is the only honest outcome: the substituted page is somebody else's, and reporting `ok` for it would hide that the requested page is gone.
+ failActionCallback(ap, normalizeParams(body?.params), errMsg)
+ return
+ }
+ }
+ handleContainerMsg(ap, routing.page, msg, ctx, state)
}
}
@@ -2119,6 +2277,26 @@ function handleAppLifecycleToggle(
return false
}
+// wx.onWindowResize / offWindowResize (keep subscription).
+// The service encodes the listener as a keep callback id in `params.success` for both register and unregister, matching handleAppLifecycleToggle's pattern.
+// Returns true when `name` matched (fully handled), false to fall through.
+function handleWindowResizeToggle(
+ state: RouterState,
+ ap: AppSession,
+ name: string,
+ params: Record,
+): boolean {
+ if (name === 'onWindowResize') {
+ state.windowResize.register(ap.appSessionId, params.success)
+ return true
+ }
+ if (name === 'offWindowResize') {
+ state.windowResize.unregister(ap.appSessionId, params.success)
+ return true
+ }
+ return false
+}
+
// pageScrollTo acts on the page's render guest (scroll its document), which
// only the main process can reach — run the scroll script in the invoking
// page's render webContents rather than forwarding to the simulator.
@@ -2304,6 +2482,8 @@ async function handleSimulatorApi(
if (handleAppLifecycleToggle(state, ap, name, params)) return
+ if (handleWindowResizeToggle(state, ap, name, params)) return
+
if (name === 'pageScrollTo') {
handlePageScrollApi(ap, page, params)
return
@@ -2373,16 +2553,12 @@ function forwardApiCallToSimulator(
const keep = params.keep === true || isPersistentSimulatorApi(name)
const timer = keep
? undefined
- : setTimeout(() => {
- const pending = state.pendingApiCalls.get(requestId)
- if (!pending) return
- state.pendingApiCalls.delete(requestId)
- const target = state.appSessions.get(pending.appSessionId)
- if (!target) return
- const fail = { errMsg: `${pending.name}:fail no handler (timeout)` }
- sendCallback(target, pending.callbacks.fail, fail)
- sendCallback(target, pending.callbacks.complete, fail)
- }, apiCallWatchdogMs(name, params))
+ : armApiCallWatchdog(
+ state,
+ requestId,
+ apiName => `${apiName}:fail no handler (timeout)`,
+ apiCallWatchdogMs(name, params),
+ )
state.pendingApiCalls.set(requestId, {
appSessionId: ap.appSessionId,
@@ -2519,6 +2695,28 @@ function sendCallback(ap: AppSession, id: unknown, args: unknown): void {
})
}
+/**
+ * Arm the deadline a pending API call dies on.
+ * Whoever wins the race — the ack or this timer — the record is removed exactly once, so the caller's `fail` and `complete` fire at most once. `reason` builds the errMsg from the call's own recorded name, which outlives the local variable the caller used.
+ */
+function armApiCallWatchdog(
+ state: RouterState,
+ requestId: string,
+ reason: (apiName: string) => string,
+ timeoutMs: number,
+): ReturnType {
+ return setTimeout(() => {
+ const pending = state.pendingApiCalls.get(requestId)
+ if (!pending) return
+ state.pendingApiCalls.delete(requestId)
+ const target = state.appSessions.get(pending.appSessionId)
+ if (!target) return
+ const fail = { errMsg: reason(pending.name) }
+ sendCallback(target, pending.callbacks.fail, fail)
+ sendCallback(target, pending.callbacks.complete, fail)
+ }, timeoutMs)
+}
+
// ── Resource helpers ────────────────────────────────────────────────────────
function makeLoadResource(ap: AppSession, page: PageSession, target: 'service' | 'render'): MessageEnvelope {
@@ -2589,13 +2787,6 @@ function ensureRenderBound(state: RouterState, sender: WebContents, bridgeId: st
return page
}
-function pageFromMsg(state: RouterState, ap: AppSession, msg: MessageEnvelope): PageSession | undefined {
- const target = readBridgeId(msg)
- if (!target) return undefined
- const page = ap.pages.get(target)
- return page
-}
-
function appByWc(state: RouterState, wc: WebContents): AppSession | undefined {
if (wc.isDestroyed()) return undefined
const appSessionId = state.serviceWcIdToAppSessionId.get(wc.id)
@@ -2679,16 +2870,13 @@ function disposePageSession(state: RouterState, ap: AppSession, page: PageSessio
}
ap.pages.delete(page.bridgeId)
state.pageSessions.delete(page.bridgeId)
- // A page is closed before the shell has re-rendered and reported its new top,
- // so these two would go on naming a page that no longer exists — and callers
- // read them meanwhile (panels resolving a target, automation reading the
- // stack). Clear them and let the shell's next ACTIVE_PAGE / PAGE_STACK fill
- // them in. `getActiveBridgeId`'s own root fallback is guarded on the root
- // page still being in `ap.pages`, which this delete has already settled.
+ // A closed page cannot remain either the targeting authority or the owner of pending API work.
+ // The frame republishes the new top immediately afterwards.
if (ap.activeBridgeId === page.bridgeId) {
ap.activeBridgeId = null
}
ap.pageStack = undefined
+ state.emitPageClosed({ appSessionId: ap.appSessionId, bridgeId: page.bridgeId })
}
// Drain any pending API calls owned by this app session. One-shot calls
@@ -2719,6 +2907,8 @@ function closeSessionPages(state: RouterState, ap: AppSession): void {
try { page.renderWc.close() } catch { /* guest already gone */ }
}
state.pageSessions.delete(page.bridgeId)
+ // Session teardown ends every page it owns, and consumers keyed by bridgeId have no other way to learn that: the session-level 'session-orientation' teardown carries no page identity.
+ state.emitPageClosed({ appSessionId: ap.appSessionId, bridgeId: page.bridgeId })
}
}
@@ -2802,7 +2992,20 @@ async function disposeAppSession(
ap.registryHandle = null
void registryHandle?.dispose()
state.appLifecycle.dispose(appSessionId)
+ state.windowResize.dispose(appSessionId)
state.nativeWebSocket.disposeOwner(appSessionId)
+ // No mini-app forces an orientation anymore — the renderer's host-env mirror falls back to the device orientation, so closing a session restores the phone's own orientation; the user may always rotate freely once no session constrains the top page.
+ // Only true for the session that actually held the screen: a soft reload disposes the OUTGOING session after the incoming one was promoted, and that teardown must leave the promoted session's mirror alone.
+ // The claim dies here with the session that made it.
+ const wasOnScreen = state.activeAppSessionId === appSessionId
+ if (wasOnScreen) state.activeAppSessionId = null
+ state.emitSessionOrientation({
+ appSessionId,
+ bridgeId: null,
+ orientation: null,
+ canRotate: true,
+ active: wasOnScreen,
+ })
// Evict AppData bridges FIRST — eviction enumerates `ap.pages`, which the
// page teardown below progressively empties (and finally clears).
@@ -2926,7 +3129,14 @@ function resolvePageWindowConfig(appConfig: RawAppConfig, pagePath: string): Pag
const normalized = normalizePagePath(pagePath)
const appWindow = appConfig.app?.window ?? {}
const pageWindow = appConfig.modules?.[normalized] ?? {}
+ // Untyped JSON in, so a garbage `pageOrientation` (bad compiler output, hand- edited config) is filtered out rather than trusted at the PageWindowConfig type — an invalid value is treated as unconfigured (falls through to the next source, ultimately DEFAULT_PAGE_ORIENTATION in resolvePageOrientationState).
+ const pageOrientation = isPageOrientationConfig(pageWindow.pageOrientation)
+ ? pageWindow.pageOrientation
+ : isPageOrientationConfig(appWindow.pageOrientation)
+ ? appWindow.pageOrientation
+ : undefined
return {
+ pageOrientation,
navigationBarTitleText:
pageWindow.navigationBarTitleText ?? appWindow.navigationBarTitleText ?? '',
navigationBarBackgroundColor:
diff --git a/packages/dimina-electron-runtime/src/main/ipc/container-routing.test.ts b/packages/dimina-electron-runtime/src/main/ipc/container-routing.test.ts
new file mode 100644
index 00000000..70b3db02
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/ipc/container-routing.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest'
+import { routeContainerPage, stalePageApiErrMsg } from './container-routing.js'
+
+interface Page { bridgeId: string }
+
+const root: Page = { bridgeId: 'root' }
+const child: Page = { bridgeId: 'child' }
+const pages = new Map([['root', root], ['child', child]])
+
+describe('routeContainerPage', () => {
+ it('handles a message that names no page against the default page', () => {
+ expect(routeContainerPage(pages, undefined, root)).toEqual({ page: root, staleBridgeId: null })
+ })
+
+ it('routes a message to the page it names', () => {
+ expect(routeContainerPage(pages, 'child', root)).toEqual({ page: child, staleBridgeId: null })
+ })
+
+ it('reports the named page as stale once it left the session', () => {
+ const closed = new Map([['root', root]])
+ expect(routeContainerPage(closed, 'child', root)).toEqual({ page: root, staleBridgeId: 'child' })
+ })
+})
+
+describe('stalePageApiErrMsg', () => {
+ it('lets app-scoped APIs run against the default page', () => {
+ expect(stalePageApiErrMsg('request')).toBeNull()
+ expect(stalePageApiErrMsg('showToast')).toBeNull()
+ })
+})
diff --git a/packages/dimina-electron-runtime/src/main/ipc/container-routing.ts b/packages/dimina-electron-runtime/src/main/ipc/container-routing.ts
new file mode 100644
index 00000000..a00686bc
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/ipc/container-routing.ts
@@ -0,0 +1,32 @@
+/**
+ * Resolving which page a service→container message acts on.
+ *
+ * The service names the page it is running for (`msg.body.bridgeId`).
+ * A message that names nothing is app-scoped and is handled against the session's default page.
+ * A message that names a page which is no longer in the session is a different thing entirely: the page really is gone, and silently substituting another page would let its call act on — and report success for — someone else's page. `onUnload` reaches main after the page's PAGE_CLOSE, so this is a routine ordering, not an exotic one.
+ *
+ * The default page is still handed back for the stale case so app-scoped traffic keeps working; `staleBridgeId` tells the caller the page identity was substituted, and page-scoped APIs refuse to run on the substitute.
+ */
+
+export interface ContainerPageRouting {
+ /** The page to handle the message against. */
+ page: TPage
+ /** The page the message named, when that page is already gone; else null. */
+ staleBridgeId: string | null
+}
+
+export function routeContainerPage(
+ pages: ReadonlyMap,
+ namedBridgeId: string | undefined,
+ fallback: TPage,
+): ContainerPageRouting {
+ if (!namedBridgeId) return { page: fallback, staleBridgeId: null }
+ const page = pages.get(namedBridgeId)
+ if (page) return { page, staleBridgeId: null }
+ return { page: fallback, staleBridgeId: namedBridgeId }
+}
+
+/** No currently supported API is scoped to a stale page identity. */
+export function stalePageApiErrMsg(_name: string): string | null {
+ return null
+}
diff --git a/packages/dimina-electron-runtime/src/main/ipc/window-resize.test.ts b/packages/dimina-electron-runtime/src/main/ipc/window-resize.test.ts
new file mode 100644
index 00000000..bb5d1600
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/ipc/window-resize.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it } from 'vitest'
+import { createAppLifecycleController } from './app-lifecycle.js'
+import { createWindowResizeController } from './window-resize.js'
+
+describe('createWindowResizeController', () => {
+ it('ignores a null/undefined callback id on register', () => {
+ const controller = createWindowResizeController()
+ controller.register('app-1', undefined)
+ controller.register('app-1', null)
+ expect(controller.listeners('app-1')).toEqual([])
+ })
+
+ it('returns an empty snapshot for an unknown session', () => {
+ const controller = createWindowResizeController()
+ expect(controller.listeners('unknown')).toEqual([])
+ })
+
+ it('registers a callback id and lists it back', () => {
+ const controller = createWindowResizeController()
+ controller.register('app-1', 'cb-1')
+ expect(controller.listeners('app-1')).toEqual(['cb-1'])
+ })
+
+ it('keeps a registered callback across multiple listener reads (keep semantics)', () => {
+ const controller = createWindowResizeController()
+ controller.register('app-1', 'cb-1')
+ expect(controller.listeners('app-1')).toEqual(['cb-1'])
+ expect(controller.listeners('app-1')).toEqual(['cb-1'])
+ })
+
+ it('dedupes the same callback id registered twice', () => {
+ const controller = createWindowResizeController()
+ controller.register('app-1', 'cb-1')
+ controller.register('app-1', 'cb-1')
+ expect(controller.listeners('app-1')).toEqual(['cb-1'])
+ })
+
+ it('isolates callback ids per session', () => {
+ const controller = createWindowResizeController()
+ controller.register('app-1', 'cb-1')
+ controller.register('app-2', 'cb-2')
+ expect(controller.listeners('app-1')).toEqual(['cb-1'])
+ expect(controller.listeners('app-2')).toEqual(['cb-2'])
+ })
+
+ it('unregisters a single callback id, leaving the others in place', () => {
+ const controller = createWindowResizeController()
+ controller.register('app-1', 'cb-1')
+ controller.register('app-1', 'cb-2')
+ controller.unregister('app-1', 'cb-1')
+ expect(controller.listeners('app-1')).toEqual(['cb-2'])
+ })
+
+ it('unregister with no callback id clears every listener of the session', () => {
+ const controller = createWindowResizeController()
+ controller.register('app-1', 'cb-1')
+ controller.register('app-1', 'cb-2')
+ controller.unregister('app-1')
+ expect(controller.listeners('app-1')).toEqual([])
+ })
+
+ it('unregister on an unknown session is a no-op', () => {
+ const controller = createWindowResizeController()
+ expect(() => controller.unregister('unknown', 'cb-1')).not.toThrow()
+ expect(() => controller.unregister('unknown')).not.toThrow()
+ })
+
+ it('dispose drops all listeners for a session without affecting others', () => {
+ const controller = createWindowResizeController()
+ controller.register('app-1', 'cb-1')
+ controller.register('app-2', 'cb-2')
+ controller.dispose('app-1')
+ expect(controller.listeners('app-1')).toEqual([])
+ expect(controller.listeners('app-2')).toEqual(['cb-2'])
+ })
+
+ // `count()` is the ledger's own report for leak assertions: a session that ended, or a listener that was removed, must leave nothing standing in for it — one retained entry per project open is how this grows unbounded.
+ it('counts every listener across sessions and returns to zero as they go', () => {
+ const controller = createWindowResizeController()
+ expect(controller.count()).toBe(0)
+
+ controller.register('app-1', 'cb-1')
+ controller.register('app-1', 'cb-2')
+ controller.register('app-2', 'cb-3')
+ expect(controller.count()).toBe(3)
+
+ controller.unregister('app-1', 'cb-1')
+ expect(controller.count()).toBe(2)
+ controller.unregister('app-1', 'cb-2')
+ expect(controller.count()).toBe(1)
+ controller.dispose('app-2')
+ expect(controller.count()).toBe(0)
+ })
+
+ it('repeated register/dispose cycles leave the count exactly at baseline', () => {
+ const controller = createWindowResizeController()
+ for (let round = 0; round < 5; round++) {
+ controller.register(`app-${round}`, 'cb-a')
+ controller.register(`app-${round}`, 'cb-b')
+ controller.dispose(`app-${round}`)
+ }
+ expect(controller.count()).toBe(0)
+ })
+
+ it('dispose on an unknown session is a no-op', () => {
+ const controller = createWindowResizeController()
+ expect(() => controller.dispose('unknown')).not.toThrow()
+ })
+
+ // The service dedups keep callbacks by function identity, so one listener reused across two subscription APIs reaches main under a single id.
+ // Each registry owns its own entry for that id: removing the resize listener leaves the app-lifecycle listener registered.
+ it('removing an id shared with an app-lifecycle listener leaves that listener registered', () => {
+ const resize = createWindowResizeController()
+ const lifecycle = createAppLifecycleController()
+ resize.register('app-1', 'cb-shared')
+ lifecycle.register('app-1', 'onAppShow', 'cb-shared')
+
+ resize.unregister('app-1', 'cb-shared')
+
+ expect(resize.listeners('app-1')).toEqual([])
+ expect(lifecycle.listeners('app-1', 'onAppShow')).toEqual(['cb-shared'])
+ })
+})
diff --git a/packages/dimina-electron-runtime/src/main/ipc/window-resize.ts b/packages/dimina-electron-runtime/src/main/ipc/window-resize.ts
new file mode 100644
index 00000000..9c16c4b5
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/ipc/window-resize.ts
@@ -0,0 +1,72 @@
+/**
+ * Runtime per-app-session registry for `wx.onWindowResize` listeners.
+ *
+ * `wx.onWindowResize(listener)` arrives as a keep subscription — the service encodes `listener` as a persistent callback id in `params.success` (service `callback.store(fn, keep=true)`), so the router stores the id here and re-fires it via `sendCallback` on every dispatched PAGE_RESIZE, the same pattern `app-lifecycle.ts` uses for `wx.onAppShow`.
+ *
+ * `wx.offWindowResize(listener)` carries the id the matching `on` produced — the service keeps its own `listener → evtId` map for this API — so `off` removes exactly that listener. `wx.offWindowResize()` with no argument carries no id and clears every listener of the session (WeChat contract).
+ *
+ * The ids are opaque here.
+ * Whether two ids collide across APIs is the service's business; this registry only ever adds and removes what the service names, and never touches another API's registry.
+ */
+
+export interface WindowResizeController {
+ /** Store a keep callback id for a session. Null/undefined ids are ignored. */
+ register(appSessionId: string, callbackId: unknown): void
+ /**
+ * Remove a listener.
+ * With `callbackId`, removes only that id; without one, clears every listener registered for the session.
+ */
+ unregister(appSessionId: string, callbackId?: unknown): void
+ /** Snapshot of the registered callback ids (empty for an unknown session). */
+ listeners(appSessionId: string): unknown[]
+ /**
+ * Total registered listeners across every session.
+ * The ledger's own count, for leak assertions that must return to a baseline exactly after churn — an emptied-but-retained session bucket is itself a leak, so a session with no listeners contributes 0 and leaves no trace.
+ */
+ count(): number
+ /** Drop all listeners for a torn-down session. */
+ dispose(appSessionId: string): void
+}
+
+export function createWindowResizeController(): WindowResizeController {
+ const sessions = new Map>()
+
+ return {
+ register(appSessionId, callbackId) {
+ if (callbackId === undefined || callbackId === null) return
+ let ids = sessions.get(appSessionId)
+ if (!ids) {
+ ids = new Set()
+ sessions.set(appSessionId, ids)
+ }
+ ids.add(callbackId)
+ },
+
+ unregister(appSessionId, callbackId) {
+ if (callbackId === undefined || callbackId === null) {
+ sessions.delete(appSessionId)
+ return
+ }
+ const ids = sessions.get(appSessionId)
+ if (!ids) return
+ ids.delete(callbackId)
+ // Drop the bucket with its last listener: an empty Set left behind is an entry that outlives every reason for it to exist.
+ if (ids.size === 0) sessions.delete(appSessionId)
+ },
+
+ listeners(appSessionId) {
+ const ids = sessions.get(appSessionId)
+ return ids ? Array.from(ids) : []
+ },
+
+ count() {
+ let total = 0
+ for (const ids of sessions.values()) total += ids.size
+ return total
+ },
+
+ dispose(appSessionId) {
+ sessions.delete(appSessionId)
+ },
+ }
+}
diff --git a/packages/dimina-electron-runtime/src/main/runtime-events.ts b/packages/dimina-electron-runtime/src/main/runtime-events.ts
index c397a43a..49b6e9bc 100644
--- a/packages/dimina-electron-runtime/src/main/runtime-events.ts
+++ b/packages/dimina-electron-runtime/src/main/runtime-events.ts
@@ -1,5 +1,6 @@
import type { SyncStorageChange } from '../shared/runtime-types.js'
import type { MessageEnvelope } from '../shared/bridge-channels.js'
+import type { Orientation } from '../shared/page-orientation.js'
export interface SessionRuntimeStatus {
appId: string
@@ -9,11 +10,42 @@ export interface SessionRuntimeStatus {
pageFallback?: { requested: string; resolved: string }
}
+/**
+ * Broadcast on every PAGE_RESIZE and on session teardown so the renderer's host-env mirror can track the orientation an app session forces, without recomputing it — DeviceShell is the sole authority (see `shared/page-orientation.ts`). `orientation: null` means no session is forcing one (falls back to the device orientation); `canRotate` mirrors whether the top page lets the user rotate the simulated device.
+ */
+export interface SessionOrientationEvent {
+ appSessionId: string
+ /**
+ * The page this orientation belongs to.
+ * Consumers doing per-page work — the CSS `env(safe-area-inset-*)` override of that page's own render guest — route by it, so a hidden tab-substack guest is never given the top page's orientation. `null` on teardown, where no page is reporting one.
+ */
+ bridgeId: string | null
+ orientation: Orientation | null
+ canRotate: boolean
+ /**
+ * Whether this report comes from the session the simulator declared as the one on screen (`SESSION_ACTIVE`).
+ * Consumers mirroring "what the user is looking at" — the renderer's panel geometry and rotate control — must honor nothing else: during a soft reload the outgoing session keeps reporting after the incoming one has taken the screen, and its eventual teardown arrives last of all.
+ * Per-page consumers (a render guest's own safe-area override) ignore this and route by `bridgeId` instead.
+ */
+ active: boolean
+}
+
+/**
+ * A page's own end — `PAGE_CLOSE` or the teardown of the session it belongs to.
+ * Consumers holding per-page state keyed by `bridgeId` release it here rather than off the render guest's `'destroyed'`: a page outlives its guest across a render-host swap, and a page can exist before any guest attaches.
+ */
+export interface PageClosedEvent {
+ appSessionId: string
+ bridgeId: string
+}
+
export interface RuntimeEventMap {
'session-status': SessionRuntimeStatus
'app-data-evict': { appId: string; bridgeId: string }
'app-data-message': { appId: string; message: MessageEnvelope }
'storage-changed': { appId: string; change: SyncStorageChange }
+ 'session-orientation': SessionOrientationEvent
+ 'page-closed': PageClosedEvent
}
export interface RuntimeEvents {
diff --git a/packages/dimina-electron-runtime/src/shared/bridge-channels.ts b/packages/dimina-electron-runtime/src/shared/bridge-channels.ts
index f0846f27..d3ca5891 100644
--- a/packages/dimina-electron-runtime/src/shared/bridge-channels.ts
+++ b/packages/dimina-electron-runtime/src/shared/bridge-channels.ts
@@ -1,4 +1,8 @@
-import type { NativeDeviceInfo } from './runtime-types.js'
+import type { NativeDeviceInfo, SafeAreaInsets } from './runtime-types.js'
+import type { Orientation, PageOrientationConfig } from './page-orientation.js'
+import { orientedDeviceMetrics, orientedSafeAreaInsets } from './page-orientation.js'
+
+export { SERVICE_HOST_CHANNELS } from './service-host-channels.js'
export const BRIDGE_CHANNELS = {
SPAWN: 'dmb:spawn',
@@ -6,6 +10,11 @@ export const BRIDGE_CHANNELS = {
PAGE_OPEN: 'dmb:page:open',
PAGE_CLOSE: 'dmb:page:close',
PAGE_LIFECYCLE: 'dmb:page:lifecycle',
+ /**
+ * simulator (DeviceShell) → main: the page window changed orientation/size.
+ * Payload is `PageResizePayload` (shared/page-orientation).
+ */
+ PAGE_RESIZE: 'dmb:page:resize',
NAV_CALLBACK: 'dmb:nav:callback',
SERVICE_INVOKE: 'dmb:service:invoke',
SERVICE_PUBLISH: 'dmb:service:publish',
@@ -36,6 +45,10 @@ export const BRIDGE_CHANNELS = {
* this to report multi-page stacks. Fire-and-forget.
*/
PAGE_STACK: 'dmb:page-stack',
+ /** simulator (DeviceShell) → main: the app session whose shell is on screen.
+ * Soft reload has two sessions reporting at once, so main cannot infer it from who published last.
+ * Fire-and-forget; the claim dies with the session. */
+ SESSION_ACTIVE: 'dmb:session-active',
} as const
export const SIMULATOR_EVENTS = {
@@ -69,35 +82,19 @@ export const SimulatorCustomApiBridgeChannel = {
Response: 'simulator:custom-apis:bridge-response',
} as const
-/**
- * `simulator:relaunch` payload. `url` is a full simulator URL (same format as
- * the simulator page's own location / AttachNative), carrying the appId and
- * the page route the new session must boot at.
- */
+/** `simulator:relaunch` payload: a full simulator URL (same format as the simulator page's own location / AttachNative) carrying the appId + page route the new session must boot at. */
export interface RelaunchPayload {
url: string
}
export const CHANNELS = BRIDGE_CHANNELS
-/**
- * Reply to a `NATIVE_HOST_ENABLED` sendSync. Main supplies render-host asset
- * URLs (preload still needs a `file://` path for the guest preload script).
- * The pageFrame document itself is built per-bridge as `dmb-resource://…`
- * via `buildRenderHostDocumentUrl` — `renderHostHtmlUrl` is only a legacy
- * placeholder kept for the config shape.
- */
+/** Reply to a `NATIVE_HOST_ENABLED` sendSync. Main supplies render-host asset URLs (preload still needs a `file://` path for the guest preload script); the pageFrame document itself is built per-bridge as `dmb-resource://…` via `buildRenderHostDocumentUrl` — `renderHostHtmlUrl` is only a legacy placeholder kept for the config shape. */
export interface NativeHostConfig {
enabled: boolean
renderHostHtmlUrl: string
renderPreloadUrl: string
- /**
- * The currently-selected device, if the renderer already pushed it before the
- * simulator WCV's preload installed (it does — SetDeviceInfo precedes
- * AttachNative). DeviceShell reads this as its initial device so it never
- * mounts with the wrong bezel size while waiting for the first DEVICE_CHANGE.
- * Absent only on the pre-spawn default path.
- */
+ /** The currently-selected device, if the renderer already pushed it before the simulator WCV's preload installed (it does — SetDeviceInfo precedes AttachNative); DeviceShell reads this as its initial device so it never mounts with the wrong bezel size while waiting for the first DEVICE_CHANGE. Absent only on the pre-spawn default path. */
device?: NativeDeviceInfo
}
@@ -159,6 +156,10 @@ export interface HostEnvSnapshot {
statusBarHeight: number
language: string
theme: string
+ /** Orientation the mini-app window currently shows. */
+ deviceOrientation?: Orientation
+ /** Safe-area insets for the orientation currently on screen (see `orientedSafeAreaInsets`). */
+ safeAreaInsets?: SafeAreaInsets
[key: string]: unknown
}
@@ -176,17 +177,24 @@ export interface HostEnvSnapshot {
* update pushed — otherwise a respawn would silently revert to the boot device.
*/
export function deviceInfoToHostEnv(d: NativeDeviceInfo): Partial {
+ const deviceOrientation: Orientation = d.deviceOrientation ?? 'portrait'
+ const m = orientedDeviceMetrics(d, deviceOrientation)
return {
brand: d.brand,
model: d.model,
system: d.system,
platform: d.platform,
pixelRatio: d.pixelRatio,
- screenWidth: d.screenWidth,
- screenHeight: d.screenHeight,
- windowWidth: d.screenWidth,
- windowHeight: Math.max(0, d.screenHeight - d.statusBarHeight),
- statusBarHeight: d.statusBarHeight,
+ screenWidth: m.screenWidth,
+ screenHeight: m.screenHeight,
+ windowWidth: m.screenWidth,
+ windowHeight: Math.max(0, m.screenHeight - m.statusBarHeight),
+ statusBarHeight: m.statusBarHeight,
+ deviceOrientation,
+ safeAreaInsets: orientedSafeAreaInsets(
+ { statusBarHeight: d.statusBarHeight, hasNotch: d.notchType !== 'none', safeAreaInsets: d.safeAreaInsets },
+ deviceOrientation,
+ ),
}
}
@@ -199,6 +207,7 @@ export function deviceInfoToHostEnv(d: NativeDeviceInfo): Partial {
+ it('accepts the three documented values', () => {
+ expect(isPageOrientationConfig('portrait')).toBe(true)
+ expect(isPageOrientationConfig('auto')).toBe(true)
+ expect(isPageOrientationConfig('landscape')).toBe(true)
+ })
+
+ it('rejects values outside the enum, matching the WeChat devtools validator', () => {
+ expect(isPageOrientationConfig('Portrait')).toBe(false)
+ expect(isPageOrientationConfig('LANDSCAPE')).toBe(false)
+ expect(isPageOrientationConfig('')).toBe(false)
+ expect(isPageOrientationConfig('vertical')).toBe(false)
+ expect(isPageOrientationConfig(undefined)).toBe(false)
+ expect(isPageOrientationConfig(null)).toBe(false)
+ expect(isPageOrientationConfig(0)).toBe(false)
+ expect(isPageOrientationConfig({})).toBe(false)
+ })
+})
+
+describe('resolvePageOrientationState', () => {
+ it('treats an unknown pageOrientation value as portrait', () => {
+ expect(resolvePageOrientationState('sideways')).toEqual({
+ originalPageOrientation: 'portrait',
+ })
+ })
+
+ it('treats a missing pageOrientation value as portrait', () => {
+ expect(resolvePageOrientationState(undefined)).toEqual({
+ originalPageOrientation: 'portrait',
+ })
+ })
+
+ it('treats null as portrait', () => {
+ expect(resolvePageOrientationState(null)).toEqual({
+ originalPageOrientation: 'portrait',
+ })
+ })
+
+ it('is case-sensitive: a differently-cased value is still dirty and falls back to portrait', () => {
+ expect(resolvePageOrientationState('Auto')).toEqual({
+ originalPageOrientation: 'portrait',
+ })
+ })
+
+ it('resolves "auto" from the config', () => {
+ expect(resolvePageOrientationState('auto')).toEqual({
+ originalPageOrientation: 'auto',
+ })
+ })
+
+ it('resolves a fixed "landscape" config', () => {
+ expect(resolvePageOrientationState('landscape')).toEqual({
+ originalPageOrientation: 'landscape',
+ })
+ })
+
+ it('resolves a fixed "portrait" config', () => {
+ expect(resolvePageOrientationState('portrait')).toEqual({
+ originalPageOrientation: 'portrait',
+ })
+ })
+})
+
+describe('computedOrientationConfig', () => {
+ it('returns the resolved originalPageOrientation', () => {
+ const state: PageOrientationState = {
+ originalPageOrientation: 'landscape',
+ }
+ expect(computedOrientationConfig(state)).toBe('landscape')
+ })
+})
+
+describe('effectiveOrientation', () => {
+ it('resolves to the device orientation when computed config is "auto"', () => {
+ const state: PageOrientationState = { originalPageOrientation: 'auto' }
+ expect(effectiveOrientation(state, 'landscape')).toBe('landscape')
+ expect(effectiveOrientation(state, 'portrait')).toBe('portrait')
+ })
+
+ it('resolves to the fixed computed config, ignoring the device orientation', () => {
+ const state: PageOrientationState = { originalPageOrientation: 'landscape' }
+ expect(effectiveOrientation(state, 'portrait')).toBe('landscape')
+ })
+})
+
+describe('canUserRotate', () => {
+ it('allows manual rotation when the computed config is "auto"', () => {
+ const state: PageOrientationState = { originalPageOrientation: 'auto' }
+ expect(canUserRotate(state)).toBe(true)
+ })
+
+ it('disables manual rotation for a fixed "landscape" page', () => {
+ const state: PageOrientationState = { originalPageOrientation: 'landscape' }
+ expect(canUserRotate(state)).toBe(false)
+ })
+
+ it('disables manual rotation for a fixed "portrait" page', () => {
+ const state: PageOrientationState = { originalPageOrientation: 'portrait' }
+ expect(canUserRotate(state)).toBe(false)
+ })
+})
+
+describe('orientedDeviceMetrics', () => {
+ it('returns the device metrics unchanged in portrait', () => {
+ expect(orientedDeviceMetrics(device, 'portrait')).toEqual({
+ screenWidth: 375,
+ screenHeight: 667,
+ statusBarHeight: 20,
+ })
+ })
+
+ it('swaps width and height in landscape', () => {
+ const result = orientedDeviceMetrics(device, 'landscape')
+ expect(result.screenWidth).toBe(667)
+ expect(result.screenHeight).toBe(375)
+ })
+
+ it('zeroes the status bar height in landscape, matching phone devtools semantics', () => {
+ const result = orientedDeviceMetrics(device, 'landscape')
+ expect(result.statusBarHeight).toBe(0)
+ })
+
+ it('keeps the original status bar height in portrait even when it is 0', () => {
+ const notch = { screenWidth: 390, screenHeight: 844, statusBarHeight: 0 }
+ expect(orientedDeviceMetrics(notch, 'portrait').statusBarHeight).toBe(0)
+ })
+})
+
+describe('normalizeDeviceOrientation', () => {
+ it('uses the supplied orientation when it is a valid value', () => {
+ expect(normalizeDeviceOrientation({ windowWidth: 375, windowHeight: 667 }, 'landscape')).toBe('landscape')
+ expect(normalizeDeviceOrientation({ windowWidth: 667, windowHeight: 375 }, 'portrait')).toBe('portrait')
+ })
+
+ it('falls back to width/height comparison when the orientation is missing', () => {
+ expect(normalizeDeviceOrientation({ windowWidth: 667, windowHeight: 375 })).toBe('landscape')
+ expect(normalizeDeviceOrientation({ windowWidth: 375, windowHeight: 667 })).toBe('portrait')
+ })
+
+ it('falls back to width/height comparison when the orientation value is invalid', () => {
+ expect(normalizeDeviceOrientation({ windowWidth: 667, windowHeight: 375 }, 'undefined')).toBe('landscape')
+ expect(normalizeDeviceOrientation({ windowWidth: 375, windowHeight: 667 }, 'sideways')).toBe('portrait')
+ expect(normalizeDeviceOrientation({ windowWidth: 667, windowHeight: 375 }, null)).toBe('landscape')
+ expect(normalizeDeviceOrientation({ windowWidth: 667, windowHeight: 375 }, 42)).toBe('landscape')
+ })
+
+ it('treats an exact square as portrait, since width is not strictly greater than height', () => {
+ expect(normalizeDeviceOrientation({ windowWidth: 400, windowHeight: 400 })).toBe('portrait')
+ })
+})
+
+describe('shouldDispatchResize', () => {
+ const autoState: PageOrientationState = { originalPageOrientation: 'auto' }
+ const fixedState: PageOrientationState = { originalPageOrientation: 'landscape' }
+
+ const portrait = { windowWidth: 375, windowHeight: 667, deviceOrientation: 'portrait' as const }
+ const landscape = { windowWidth: 667, windowHeight: 375, deviceOrientation: 'landscape' as const }
+
+ it('leaves the window channel silent when the geometry did not move', () => {
+ expect(
+ shouldDispatchResize({ state: autoState, previous: portrait, next: { ...portrait } }),
+ ).toEqual({ dispatchWindow: false, dispatchPage: true })
+ })
+
+ it('reports the page channel on a landing whose window never moved', () => {
+ // A route commit names its landing page without comparing geometry, so a page returning into a window that rotated while it was hidden re-reads its own window instead of keeping a stale rpx basis.
+ expect(
+ shouldDispatchResize({ state: autoState, previous: landscape, next: { ...landscape } }),
+ ).toEqual({ dispatchWindow: false, dispatchPage: true })
+ })
+
+ it('opens the window channel on the very first report, whose baseline is still empty', () => {
+ expect(
+ shouldDispatchResize({ state: autoState, previous: EMPTY_RESIZE_BASELINE, next: landscape }),
+ ).toEqual({ dispatchWindow: true, dispatchPage: true })
+ })
+
+ it('suppresses both channels for a fixed-orientation page, even though the geometry moved', () => {
+ expect(
+ shouldDispatchResize({ state: fixedState, previous: portrait, next: landscape }),
+ ).toEqual({ dispatchWindow: false, dispatchPage: false })
+ })
+
+ it('dispatches for an "auto" page when the device orientation changed', () => {
+ expect(
+ shouldDispatchResize({ state: autoState, previous: portrait, next: landscape }),
+ ).toEqual({ dispatchWindow: true, dispatchPage: true })
+ })
+
+ it('dispatches for an "auto" page when only the window size changed but the orientation label did not', () => {
+ expect(
+ shouldDispatchResize({
+ state: autoState,
+ previous: portrait,
+ next: { windowWidth: 390, windowHeight: 667, deviceOrientation: 'portrait' },
+ }),
+ ).toEqual({ dispatchWindow: true, dispatchPage: true })
+ })
+})
+
+describe('orientedSafeAreaInsets', () => {
+ // iPhone X profile: the numbers WeChat itself ships for this screen.
+ const notched = {
+ statusBarHeight: 44,
+ hasNotch: true,
+ safeAreaInsets: { top: 44, right: 0, bottom: 34, left: 0 },
+ }
+ const flat = {
+ statusBarHeight: 20,
+ hasNotch: false,
+ safeAreaInsets: { top: 20, right: 0, bottom: 0, left: 0 },
+ }
+
+ it('returns the portrait insets untouched in portrait', () => {
+ expect(orientedSafeAreaInsets(notched, 'portrait')).toEqual({ top: 44, right: 0, bottom: 34, left: 0 })
+ })
+
+ it('moves the notch from the top edge onto both sides in landscape', () => {
+ expect(orientedSafeAreaInsets(notched, 'landscape')).toEqual({ top: 0, right: 44, bottom: 21, left: 44 })
+ })
+
+ it('leaves a device without a notch inset-free in landscape', () => {
+ expect(orientedSafeAreaInsets(flat, 'landscape')).toEqual({ top: 0, right: 0, bottom: 0, left: 0 })
+ })
+
+ it('computes the landscape insets rather than transposing the portrait ones', () => {
+ const landscape = orientedSafeAreaInsets(notched, 'landscape')
+ // Transposing would have carried the portrait bottom (34) across; the real landscape home indicator is thinner, and the top frees up entirely.
+ expect(landscape.bottom).not.toBe(notched.safeAreaInsets.bottom)
+ expect(landscape.top).toBe(0)
+ })
+})
diff --git a/packages/dimina-electron-runtime/src/shared/page-orientation.ts b/packages/dimina-electron-runtime/src/shared/page-orientation.ts
new file mode 100644
index 00000000..583cfc79
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/shared/page-orientation.ts
@@ -0,0 +1,317 @@
+/**
+ * Screen-orientation policy, shared by the simulator shell (which owns the page stack and is therefore the authority on the effective orientation), the renderer panel geometry, and the main-process resize dispatcher.
+ *
+ * The semantics mirror WeChat's documented `pageOrientation` configuration: each page resolves its own json falling back to `app.json`'s `window` section, and only pages whose resolved config is `auto` follow the device.
+ *
+ * Everything here is pure so all three consumers derive identical answers from the same inputs — the effective orientation has exactly one authority and nobody re-implements the rules locally.
+ */
+
+export type Orientation = 'portrait' | 'landscape'
+export type PageOrientationConfig = 'portrait' | 'auto' | 'landscape'
+
+/** Orientation config default when a page and the app both stay silent. */
+export const DEFAULT_PAGE_ORIENTATION: PageOrientationConfig = 'portrait'
+
+const PAGE_ORIENTATION_CONFIGS: readonly PageOrientationConfig[] = ['portrait', 'auto', 'landscape']
+
+export interface PageOrientationState {
+ /** Config value resolved from `page.json` ?? `app.json`.window ?? portrait. */
+ originalPageOrientation: PageOrientationConfig
+}
+
+export interface DeviceMetricsInput {
+ /** Portrait-baseline screen width in px. */
+ screenWidth: number
+ /** Portrait-baseline screen height in px. */
+ screenHeight: number
+ statusBarHeight: number
+}
+
+export interface OrientedMetrics {
+ screenWidth: number
+ screenHeight: number
+ statusBarHeight: number
+}
+
+export interface ResizeSize {
+ windowWidth: number
+ windowHeight: number
+}
+
+/**
+ * The `size` a host reports.
+ * The base library passes this object straight through to the callbacks, so what it carries is the host's choice: the documented `windowWidth`/`windowHeight` plus the whole screen, which is the same pair the native hosts send.
+ * The two differ by the chrome the system keeps — status bar and navigation bar — and both swap width/height on rotation.
+ */
+export interface ResizeReportSize extends ResizeSize {
+ screenWidth: number
+ screenHeight: number
+}
+
+/** The object `Page.onResize`, the `resize` page lifetime and
+ * `wx.onWindowResize` listeners all receive, matching WeChat's payload. */
+export interface PageResizeDetail {
+ size: ResizeReportSize
+ deviceOrientation: Orientation
+}
+
+/** DeviceShell → main payload for the `PAGE_RESIZE` channel. */
+export interface PageResizePayload {
+ appSessionId: string
+ bridgeId: string
+ size: ResizeReportSize
+ deviceOrientation: Orientation
+ /**
+ * Whether this change fires `wx.onWindowResize`.
+ * DeviceShell applies the gating rules (see {@link shouldDispatchResize}); main refreshes the host-env snapshot regardless of either dispatch field.
+ */
+ dispatchWindow: boolean
+ /** Whether this change fires `Page.onResize` / component `resize` — independent of {@link dispatchWindow}. */
+ dispatchPage: boolean
+ /**
+ * Whether the top page lets the user rotate the simulated device (see
+ * {@link canUserRotate}). Main relays it to the renderer so the rotate
+ * control reflects the page currently on screen.
+ */
+ canRotate: boolean
+}
+
+export function isOrientation(value: unknown): value is Orientation {
+ return value === 'portrait' || value === 'landscape'
+}
+
+export function isPageOrientationConfig(value: unknown): value is PageOrientationConfig {
+ return typeof value === 'string' && PAGE_ORIENTATION_CONFIGS.includes(value as PageOrientationConfig)
+}
+
+/**
+ * Build a page's orientation state from its resolved window config.
+ * Unknown or missing values fall back to portrait, which is also WeChat's default.
+ */
+export function resolvePageOrientationState(configured: unknown): PageOrientationState {
+ return {
+ originalPageOrientation: isPageOrientationConfig(configured)
+ ? configured
+ : DEFAULT_PAGE_ORIENTATION,
+ }
+}
+
+/** The page's resolved orientation configuration. */
+export function computedOrientationConfig(state: PageOrientationState): PageOrientationConfig {
+ return state.originalPageOrientation
+}
+
+/** What the page actually shows: `auto` follows the device, else the config. */
+export function effectiveOrientation(
+ state: PageOrientationState,
+ deviceOrientation: Orientation,
+): Orientation {
+ const computed = computedOrientationConfig(state)
+ return computed === 'auto' ? deviceOrientation : computed
+}
+
+/**
+ * Whether the user may rotate the simulated device while this page is on top.
+ * Pages pinned to a fixed orientation ignore device rotation, so the control is inert for them.
+ */
+export function canUserRotate(state: PageOrientationState): boolean {
+ return computedOrientationConfig(state) === 'auto'
+}
+
+/**
+ * Device metrics for a given orientation.
+ * Landscape swaps the portrait baseline width/height and drops the status bar, which is what WeChat does on phones; the navigation bar and tab bar keep their heights.
+ */
+export function orientedDeviceMetrics(
+ device: DeviceMetricsInput,
+ orientation: Orientation,
+): OrientedMetrics {
+ if (orientation !== 'landscape') {
+ return {
+ screenWidth: device.screenWidth,
+ screenHeight: device.screenHeight,
+ statusBarHeight: device.statusBarHeight,
+ }
+ }
+ return {
+ screenWidth: device.screenHeight,
+ screenHeight: device.screenWidth,
+ statusBarHeight: 0,
+ }
+}
+
+export interface SafeAreaInsetsShape {
+ top: number
+ right: number
+ bottom: number
+ left: number
+}
+
+export interface SafeAreaInput {
+ /** Portrait-baseline status bar height. In landscape the notch eats this much off each side. */
+ statusBarHeight: number
+ /** Whether the screen has a notch or dynamic island cutting into it. */
+ hasNotch: boolean
+ /** Portrait-baseline insets. */
+ safeAreaInsets: SafeAreaInsetsShape
+}
+
+/**
+ * Home-indicator inset a notched iPhone keeps at the bottom in landscape.
+ * WeChat lists 21 for every notched iPhone it ships a profile for, and its base library's safe-area fallback for 812x375@3x resolves `--safe-area-inset-bottom: 21px` — two independent statements of the same number, against 34 in portrait.
+ */
+const LANDSCAPE_HOME_INDICATOR = 21
+
+/**
+ * Safe-area insets for a given orientation.
+ * Landscape is not the portrait insets rotated: the notch moves from the top edge to BOTH side edges, the top frees up entirely, and the home indicator gets thinner.
+ *
+ * WeChat's own client recomputes this rather than transforming — on every orientation change its base library re-asks native for a fresh `safeArea` instead of deriving one — so the simulator, which stands in for native here, has to produce the landscape values itself.
+ * The side inset equals the status bar height because that is the notch's own depth.
+ */
+export function orientedSafeAreaInsets(
+ device: SafeAreaInput,
+ orientation: Orientation,
+): SafeAreaInsetsShape {
+ if (orientation !== 'landscape') return device.safeAreaInsets
+ const side = device.hasNotch ? device.statusBarHeight : 0
+ return {
+ top: 0,
+ right: side,
+ bottom: device.hasNotch ? LANDSCAPE_HOME_INDICATOR : 0,
+ left: side,
+ }
+}
+
+/** Navigation bar height; fixed, it does not follow the orientation. */
+export const NAV_BAR_HEIGHT = 44
+
+/**
+ * The tab bar row's own content height.
+ * This is not the height the tab bar occupies in the layout — see {@link tabBarReservedHeight}.
+ */
+export const TAB_BAR_HEIGHT = 50
+
+/**
+ * Height a tab bar actually reserves in the layout flow: the row's content box (`content-box` sizing puts padding on top of the row height), the home-indicator inset its background pads into, and its 1px top border.
+ */
+export function tabBarReservedHeight(bottomInset: number): number {
+ return TAB_BAR_HEIGHT + bottomInset + 1
+}
+
+/** The chrome a page keeps in the layout flow around its window area. */
+export interface PageChrome {
+ /** `custom` takes the navigation bar out of flow, so nothing is reserved above the page. */
+ navigationStyle?: 'default' | 'custom'
+ /** Whether this is a tabBar page, which keeps the tab bar in flow below it. */
+ isTab: boolean
+ /** Portrait-baseline bottom safe-area inset the tab bar pads its background into. */
+ bottomInset: number
+}
+
+/**
+ * A page's window size on a given oriented screen: the screen minus the chrome that stays in the layout flow.
+ * The status bar never reserves flow space by itself — the default navigation bar's box already spans it — so a `custom` navigation style leaves the full screen height above the tab bar.
+ *
+ * Every consumer that reports `windowWidth`/`windowHeight` to a mini-app goes through here: the simulator shell when it measures a live page, and the router when it seeds a spawn's host env before the shell has measured anything.
+ * One formula, so the seed and the first measured frame agree.
+ */
+export function pageWindowSize(oriented: OrientedMetrics, chrome: PageChrome): ResizeSize {
+ const reservedTop = chrome.navigationStyle === 'custom'
+ ? 0
+ : oriented.statusBarHeight + NAV_BAR_HEIGHT
+ const reservedBottom = chrome.isTab ? tabBarReservedHeight(chrome.bottomInset) : 0
+ return {
+ windowWidth: oriented.screenWidth,
+ windowHeight: Math.max(0, oriented.screenHeight - reservedTop - reservedBottom),
+ }
+}
+
+/** Screen metrics plus the window fields a host-env snapshot reports. */
+export interface WindowMetricsFields extends OrientedMetrics {
+ windowWidth: number
+ windowHeight: number
+}
+
+/**
+ * Rewrite a snapshot's window size to what `chrome` leaves of the snapshot's own (already oriented) screen metrics, leaving every other field untouched.
+ */
+export function withPageWindowSize(env: T, chrome: PageChrome): T {
+ return { ...env, ...pageWindowSize(env, chrome) }
+}
+
+/**
+ * WeChat's own guidance is to trust the window dimensions over a reported orientation, so an absent or malformed value is derived from the size.
+ */
+export function normalizeDeviceOrientation(
+ size: ResizeSize,
+ deviceOrientation?: unknown,
+): Orientation {
+ if (isOrientation(deviceOrientation)) return deviceOrientation
+ return size.windowWidth > size.windowHeight ? 'landscape' : 'portrait'
+}
+
+/** App-global geometry baseline a resize report is compared against. */
+export interface ResizeBaseline {
+ windowWidth: number
+ windowHeight: number
+ deviceOrientation: string
+}
+
+/**
+ * Sentinel baseline before any resize has ever been reported, mirroring WeChat's own `d="",p=0,h=0` module-level init — it is guaranteed to differ from any real geometry, so the very first-ever report already counts as a change instead of being special-cased as silent.
+ */
+export const EMPTY_RESIZE_BASELINE: ResizeBaseline = { windowWidth: 0, windowHeight: 0, deviceOrientation: '' }
+
+export interface ResizeDispatchInput {
+ state: PageOrientationState
+ /** App-global geometry baseline, shared by every page. */
+ previous: ResizeBaseline
+ next: { windowWidth: number, windowHeight: number, deviceOrientation: Orientation }
+}
+
+export interface ResizeDispatchResult {
+ /** Gates `wx.onWindowResize`: fires only when the app-global baseline actually moved. */
+ dispatchWindow: boolean
+ /** Gates `Page.onResize` / component `resize`: fires for whichever page is being reported. */
+ dispatchPage: boolean
+}
+
+/**
+ * Resize gating. Two channels with different rules:
+ *
+ * - The window channel (`wx.onWindowResize`) is app-global: it fires when the
+ * geometry moved against the baseline every page shares.
+ * One baseline for the whole mini-app, not one per page.
+ * - The page channel (`Page.onResize` / component `resize`) applies no geometry
+ * test at all: it carries whichever page the report names, so deciding WHEN to report is the host's job.
+ *
+ * Both stay silent for a page pinned to a fixed orientation. `resolveResizeDispatch` in dimina's `fe/packages/service/src/core/runtime.js` encodes the same two rules.
+ *
+ * A route commit reports its landing page unconditionally.
+ * Suppressing it when the geometry happens to match would leave a page returning from a landscape page into a still-landscape window rendering at the portrait rpx basis, with no callback to correct it — reporting the landing page is what keeps its layout answering to the window it is actually in.
+ *
+ * Deciding WHEN to report is the caller's job — see `OrientationController`, which publishes on every route commit and on device rotation.
+ *
+ * Main's `wx.onWindowResize` listener table forks off this same decision point — see `applyPageResize` in bridge-router.ts.
+ * Any change belongs in both.
+ */
+export function shouldDispatchResize(
+ { state, previous, next }: ResizeDispatchInput,
+): ResizeDispatchResult {
+ const suppressed = computedOrientationConfig(state) !== 'auto'
+
+ return {
+ dispatchWindow: movedAgainst(previous, next) && !suppressed,
+ dispatchPage: !suppressed,
+ }
+}
+
+function movedAgainst(
+ baseline: ResizeBaseline,
+ next: { windowWidth: number, windowHeight: number, deviceOrientation: Orientation },
+): boolean {
+ return baseline.windowWidth !== next.windowWidth
+ || baseline.windowHeight !== next.windowHeight
+ || baseline.deviceOrientation !== next.deviceOrientation
+}
diff --git a/packages/dimina-electron-runtime/src/shared/page-resize-host-env.test.ts b/packages/dimina-electron-runtime/src/shared/page-resize-host-env.test.ts
new file mode 100644
index 00000000..c0a0658d
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/shared/page-resize-host-env.test.ts
@@ -0,0 +1,68 @@
+/**
+ * The host-env patch a `PAGE_RESIZE` installs.
+ *
+ * Every geometry field it carries has to describe the SAME orientation — the one the page being resized is actually showing.
+ * A page pinned to landscape on a portrait phone gets landscape screen metrics, so it must also get landscape safe-area insets; pairing them with the device's portrait insets would report a top inset for a status bar that is not drawn and put the notch on an edge it does not occupy.
+ */
+import { describe, expect, it } from 'vitest'
+import { pageResizeHostEnv } from './page-resize-host-env.js'
+import type { NativeDeviceInfo } from './runtime-types.js'
+
+/** iPhone 14: notched, portrait baseline 390x844. */
+const DEVICE: NativeDeviceInfo = {
+ brand: 'Apple',
+ model: 'iPhone 14',
+ system: 'iOS 16.0',
+ platform: 'ios',
+ pixelRatio: 3,
+ screenWidth: 390,
+ screenHeight: 844,
+ statusBarHeight: 47,
+ notchType: 'dynamic-island',
+ safeAreaInsets: { top: 47, right: 0, bottom: 34, left: 0 },
+ deviceOrientation: 'portrait',
+}
+
+const LANDSCAPE_RESIZE = {
+ size: { windowWidth: 844, windowHeight: 346 },
+ deviceOrientation: 'landscape' as const,
+}
+
+const PORTRAIT_RESIZE = {
+ size: { windowWidth: 390, windowHeight: 753 },
+ deviceOrientation: 'portrait' as const,
+}
+
+describe('pageResizeHostEnv', () => {
+ it('always carries the reported window size and orientation', () => {
+ expect(pageResizeHostEnv(PORTRAIT_RESIZE, null)).toEqual({
+ windowWidth: 390,
+ windowHeight: 753,
+ deviceOrientation: 'portrait',
+ })
+ })
+
+ it('resolves the screen metrics against the resize orientation, not the device one', () => {
+ const patch = pageResizeHostEnv(LANDSCAPE_RESIZE, DEVICE)
+ expect(patch.screenWidth).toBe(844)
+ expect(patch.screenHeight).toBe(390)
+ expect(patch.statusBarHeight).toBe(0)
+ })
+
+ it('resolves the safe-area insets against the resize orientation too', () => {
+ const patch = pageResizeHostEnv(LANDSCAPE_RESIZE, DEVICE)
+ expect(patch.safeAreaInsets, 'a page drawn landscape must not keep the portrait insets')
+ .toEqual({ top: 0, right: 47, bottom: 21, left: 47 })
+ })
+
+ it('keeps the portrait insets for a page drawn portrait on a rotated device', () => {
+ const patch = pageResizeHostEnv(PORTRAIT_RESIZE, { ...DEVICE, deviceOrientation: 'landscape' })
+ expect(patch.safeAreaInsets).toEqual({ top: 47, right: 0, bottom: 34, left: 0 })
+ expect(patch.screenWidth).toBe(390)
+ expect(patch.statusBarHeight).toBe(47)
+ })
+
+ it('leaves the insets alone when no device is selected', () => {
+ expect(pageResizeHostEnv(LANDSCAPE_RESIZE, null)).not.toHaveProperty('safeAreaInsets')
+ })
+})
diff --git a/packages/dimina-electron-runtime/src/shared/page-resize-host-env.ts b/packages/dimina-electron-runtime/src/shared/page-resize-host-env.ts
new file mode 100644
index 00000000..64cff09f
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/shared/page-resize-host-env.ts
@@ -0,0 +1,33 @@
+/**
+ * The host-env fields a `PAGE_RESIZE` replaces — the page-driven counterpart to `deviceInfoToHostEnv` (shared/bridge-channels.ts), which answers the same question for a device change.
+ */
+import type { HostEnvSnapshot } from './bridge-channels.js'
+import type { NativeDeviceInfo } from './runtime-types.js'
+import { orientedDeviceMetrics, orientedSafeAreaInsets, type Orientation } from './page-orientation.js'
+
+/**
+ * Every field is resolved against the orientation the resized page is SHOWING rather than the device's own — a page pinned to landscape on a portrait phone reports landscape metrics.
+ *
+ * The safe-area insets travel with those metrics: `getSystemInfoSync` builds its `safeArea` rect by measuring the insets against the screen dimensions in the same snapshot (devtools service-host/sync-impls/system-info.ts), so leaving the device's portrait insets next to landscape dimensions would reserve a top edge for a status bar that is not drawn and put the notch on the wrong axis.
+ * Without a selected device only the size and the orientation are known.
+ */
+export function pageResizeHostEnv(
+ resize: { size: { windowWidth: number, windowHeight: number }, deviceOrientation: Orientation },
+ device: NativeDeviceInfo | null,
+): Partial {
+ const patch: Partial = {
+ windowWidth: resize.size.windowWidth,
+ windowHeight: resize.size.windowHeight,
+ deviceOrientation: resize.deviceOrientation,
+ }
+ if (!device) return patch
+ const metrics = orientedDeviceMetrics(device, resize.deviceOrientation)
+ patch.screenWidth = metrics.screenWidth
+ patch.screenHeight = metrics.screenHeight
+ patch.statusBarHeight = metrics.statusBarHeight
+ patch.safeAreaInsets = orientedSafeAreaInsets(
+ { statusBarHeight: device.statusBarHeight, hasNotch: device.notchType !== 'none', safeAreaInsets: device.safeAreaInsets },
+ resize.deviceOrientation,
+ )
+ return patch
+}
diff --git a/packages/dimina-electron-runtime/src/shared/page-window-size.test.ts b/packages/dimina-electron-runtime/src/shared/page-window-size.test.ts
new file mode 100644
index 00000000..48057912
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/shared/page-window-size.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from 'vitest'
+import {
+ NAV_BAR_HEIGHT,
+ orientedDeviceMetrics,
+ pageWindowSize,
+ tabBarReservedHeight,
+ withPageWindowSize,
+} from './page-orientation.js'
+
+/** iPhone 14 portrait baseline. */
+const device = { screenWidth: 390, screenHeight: 844, statusBarHeight: 47 }
+const BOTTOM_INSET = 34
+
+describe('pageWindowSize', () => {
+ it('reserves the status bar and the navigation bar on a default page', () => {
+ expect(pageWindowSize(device, { isTab: false, bottomInset: BOTTOM_INSET })).toEqual({
+ windowWidth: 390,
+ windowHeight: 844 - 47 - NAV_BAR_HEIGHT,
+ })
+ })
+
+ it('leaves the full screen height to a custom-navigation page', () => {
+ const size = pageWindowSize(device, {
+ navigationStyle: 'custom',
+ isTab: false,
+ bottomInset: BOTTOM_INSET,
+ })
+ expect(size).toEqual({ windowWidth: 390, windowHeight: 844 })
+ })
+
+ it('reserves the tab bar, its home-indicator padding and its border on a tab page', () => {
+ expect(tabBarReservedHeight(BOTTOM_INSET)).toBe(85)
+ const size = pageWindowSize(device, { isTab: true, bottomInset: BOTTOM_INSET })
+ expect(size.windowHeight).toBe(844 - 47 - NAV_BAR_HEIGHT - 85)
+ })
+
+ it('drops the status bar but keeps the navigation bar in landscape', () => {
+ const oriented = orientedDeviceMetrics(device, 'landscape')
+ expect(pageWindowSize(oriented, { isTab: false, bottomInset: BOTTOM_INSET })).toEqual({
+ windowWidth: 844,
+ windowHeight: 390 - NAV_BAR_HEIGHT,
+ })
+ })
+
+ it('never reports a negative height when the chrome exceeds the screen', () => {
+ const tiny = { screenWidth: 100, screenHeight: 40, statusBarHeight: 47 }
+ expect(pageWindowSize(tiny, { isTab: true, bottomInset: BOTTOM_INSET }).windowHeight).toBe(0)
+ })
+})
+
+describe('withPageWindowSize', () => {
+ /** A host-env seed carries the device's screen, so its window fields start as the whole screen minus the status bar. */
+ const seed = {
+ screenWidth: 390,
+ screenHeight: 844,
+ statusBarHeight: 47,
+ windowWidth: 390,
+ windowHeight: 844 - 47,
+ model: 'iPhone 14',
+ }
+
+ it('replaces the window size with what the page chrome leaves', () => {
+ const seeded = withPageWindowSize(seed, { isTab: false, bottomInset: BOTTOM_INSET })
+ expect(seeded.windowHeight).toBe(844 - 47 - NAV_BAR_HEIGHT)
+ expect(seeded.windowWidth).toBe(390)
+ })
+
+ it('agrees with the size the shell measures for the same page', () => {
+ const chrome = { navigationStyle: 'default' as const, isTab: true, bottomInset: BOTTOM_INSET }
+ const seeded = withPageWindowSize(seed, chrome)
+ const measured = pageWindowSize(orientedDeviceMetrics(device, 'portrait'), chrome)
+ expect({ windowWidth: seeded.windowWidth, windowHeight: seeded.windowHeight }).toEqual(measured)
+ })
+
+ it('keeps every other snapshot field untouched', () => {
+ const seeded = withPageWindowSize(seed, { isTab: false, bottomInset: BOTTOM_INSET })
+ expect(seeded.model).toBe('iPhone 14')
+ expect(seeded.screenHeight).toBe(844)
+ expect(seeded.statusBarHeight).toBe(47)
+ })
+})
diff --git a/packages/dimina-electron-runtime/src/shared/runtime-types.ts b/packages/dimina-electron-runtime/src/shared/runtime-types.ts
index 3205ed54..d2e4be38 100644
--- a/packages/dimina-electron-runtime/src/shared/runtime-types.ts
+++ b/packages/dimina-electron-runtime/src/shared/runtime-types.ts
@@ -1,3 +1,5 @@
+import type { Orientation } from './page-orientation.js'
+
export type NotchType = 'none' | 'notch' | 'dynamic-island'
export interface SafeAreaInsets {
@@ -19,6 +21,10 @@ export interface NativeDeviceInfo {
statusBarHeight: number
notchType: NotchType
safeAreaInsets: SafeAreaInsets
+ /**
+ * Orientation of the simulated device itself, which the user controls and which survives across mini-app sessions. `screenWidth`/`screenHeight` stay portrait-baseline regardless; consumers derive the rotated metrics through `orientedDeviceMetrics`.
+ */
+ deviceOrientation?: Orientation
}
/** Change emitted by synchronous storage APIs running in the service host. */
diff --git a/packages/dimina-electron-runtime/src/shared/service-host-channels.ts b/packages/dimina-electron-runtime/src/shared/service-host-channels.ts
new file mode 100644
index 00000000..f1280b41
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/shared/service-host-channels.ts
@@ -0,0 +1,9 @@
+/**
+ * Electron IPC channels the main process uses to talk to a service-host window directly, bypassing the mini-app message bus.
+ *
+ * `HostEnvUpdate` patches the spawn context's `hostEnvSnapshot` — the object the synchronous host APIs (`wx.getSystemInfoSync`, `wx.getWindowInfo`, …) read on every call.
+ * It is the ONLY way those APIs learn about new geometry: the framework-level `hostEnvUpdate` bus message feeds dimina's own host-env store instead, so a writer that needs both must send both.
+ */
+export const SERVICE_HOST_CHANNELS = {
+ HostEnvUpdate: 'service-host:host-env:update',
+} as const
diff --git a/packages/dimina-electron-runtime/src/simulator-ui/home-button-rule.test.ts b/packages/dimina-electron-runtime/src/simulator-ui/home-button-rule.test.ts
index 98ad00af..0c266b89 100644
--- a/packages/dimina-electron-runtime/src/simulator-ui/home-button-rule.test.ts
+++ b/packages/dimina-electron-runtime/src/simulator-ui/home-button-rule.test.ts
@@ -26,7 +26,7 @@
*/
import { describe, expect, it } from 'vitest'
import type { PageWindowConfig, TabBarConfig } from '../shared/bridge-channels.js'
-import { navBarFromConfig } from './page-stack-controller.js'
+import { navBarFromConfig } from './navigation-bar-config.js'
import { resolveHomeNavAction, shouldShowHomeButton } from './navigate-home.js'
const HOME = 'pages/home/home'
diff --git a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-nav-serialization.test.tsx b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-nav-serialization.test.tsx
index 8a1021f1..3a1e020c 100644
--- a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-nav-serialization.test.tsx
+++ b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-nav-serialization.test.tsx
@@ -114,8 +114,11 @@ describe('MiniAppFrame — the home button is clicked twice inside one tick', ()
expect(latestStack(recorder)).toEqual([HOME_PAGE])
expect(visiblePagePath(container)).toBe(HOME_PAGE)
expect(recorder.closedPages).toEqual([ROOT_BRIDGE_ID])
+ const home = recorder.openedEntries.find((page) => page.pagePath === HOME_PAGE)!
expect(recorder.lifecycles).toEqual([
+ { bridgeId: ROOT_BRIDGE_ID, event: 'pageShow' },
{ bridgeId: ROOT_BRIDGE_ID, event: 'pageUnload' },
+ { bridgeId: home.bridgeId, event: 'pageShow' },
])
// Ledger: an opened page is either still mounted or was handed back to the
// host for teardown. Anything else is a render host nobody owns.
diff --git a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-navigate-home-idempotent.test.tsx b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-navigate-home-idempotent.test.tsx
index aff6b23c..98d0ba5d 100644
--- a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-navigate-home-idempotent.test.tsx
+++ b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-navigate-home-idempotent.test.tsx
@@ -40,9 +40,12 @@ describe('MiniAppFrame — navigateHome runs again on the home page', () => {
expect(visiblePagePath(container)).toBe(HOME_PAGE)
})
- // The trip to home tears down the launch page and says so over the bridge.
+ // The trip to home tears down the launch page and says so over the bridge, and the home page it lands on is announced as the new visible top.
+ const home = recorder.openedEntries.find((page) => page.pagePath === HOME_PAGE)!
expect(recorder.lifecycles).toEqual([
+ { bridgeId: ROOT_BRIDGE_ID, event: 'pageShow' },
{ bridgeId: ROOT_BRIDGE_ID, event: 'pageUnload' },
+ { bridgeId: home.bridgeId, event: 'pageShow' },
])
const openedBefore = recorder.openedPages.length
const closedBefore = recorder.closedPages.length
diff --git a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-switch-tab-orphan.test.tsx b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-switch-tab-orphan.test.tsx
index b99146e9..948bcc93 100644
--- a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-switch-tab-orphan.test.tsx
+++ b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-switch-tab-orphan.test.tsx
@@ -84,11 +84,13 @@ describe('MiniAppFrame — a deep-linked non-tab launch page is left behind by s
await serviceNav(recorder, 'switchTab', HOME_PAGE)
expect(countClosed(recorder.closedPages, ROOT_BRIDGE_ID)).toBe(1)
- // The service layer hears about the page leaving the screen and dying only
- // through these bridge calls, so the delivered sequence is the assertion.
+ // The service layer hears about a page reaching the screen, leaving it and dying only through these bridge calls, so the delivered sequence is the assertion.
+ const tab = recorder.openedEntries.find((page) => page.pagePath === HOME_PAGE)!
expect(recorder.lifecycles).toEqual([
+ { bridgeId: ROOT_BRIDGE_ID, event: 'pageShow' },
{ bridgeId: ROOT_BRIDGE_ID, event: 'pageHide' },
{ bridgeId: ROOT_BRIDGE_ID, event: 'pageUnload' },
+ { bridgeId: tab.bridgeId, event: 'pageShow' },
])
})
})
diff --git a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame.tsx b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame.tsx
index 731eb46a..be214294 100644
--- a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame.tsx
+++ b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame.tsx
@@ -31,14 +31,11 @@ import {
enumerateMounted,
makeInitialShellState,
mutatePageNavBar,
- navBarFromConfig,
- normalizePath,
pageBackgroundColor,
- reduceNavBar,
type PageEntry,
type SideEffect,
} from './page-stack-controller.js'
-import { shouldShowHomeButton } from './navigate-home.js'
+import { reduceNavBar } from './navigation-bar-config.js'
import {
commitShell,
commitTabBar,
@@ -48,6 +45,7 @@ import {
doReLaunch,
doRedirectTo,
doSwitchTab,
+ makeLaunchPageEntry,
type MiniAppFrameState,
type ShellNavPayload,
} from './miniapp-routing.js'
@@ -73,6 +71,12 @@ export interface FrameChromeState {
textStyle: NavigationBarTextStyle
}
+export interface MiniAppFrameLayoutState {
+ top: PageEntry
+ mounted: ReturnType
+ tabBarVisible: boolean
+}
+
export interface MiniAppFrameProps {
host: MiniAppHost
/** The bridgeId of the page the host already spawned — the stack bottom. */
@@ -100,6 +104,10 @@ export interface MiniAppFrameProps {
statusBar?: (chrome: FrameChromeState) => ReactNode
/** Host chrome drawn above everything — extension layers, a home indicator. */
deviceOverlay?: ReactNode
+ /** Host-owned geometry authority can observe the committed page/layout state. */
+ onLayoutState?: (state: MiniAppFrameLayoutState) => void
+ /** Publishes geometry synchronously before a tab-bar API is acknowledged. */
+ onLayoutCommit?: (state: MiniAppFrameLayoutState) => void
}
export function MiniAppFrame({
@@ -111,35 +119,16 @@ export function MiniAppFrame({
onMore,
statusBar,
deviceOverlay,
+ onLayoutState,
+ onLayoutCommit,
}: MiniAppFrameProps) {
const preload = useMemo(() => host.getRenderPreloadUrl(), [host])
const tabBarConfig = useMemo(() => host.getTabBarConfig(), [host])
- const initialEntry = useMemo(() => {
- const pagePath = normalizePath(host.pagePath)
- const windowConfig = host.rootWindowConfig ?? {}
- const isTab = !!host.getTabBarConfig()?.list.some(
- item => normalizePath(item.pagePath) === pagePath,
- )
- return {
- bridgeId,
- pagePath,
- query: { ...host.query },
- isTab,
- windowConfig,
- // The launch page is the stack bottom, so a non-home, non-tab launch
- // page gets the home button by the automatic rule.
- navBar: navBarFromConfig(windowConfig, host.appId, {
- homeButtonVisible: shouldShowHomeButton({
- pagePath,
- homePagePath: host.getHomePagePath(),
- isTab,
- isStackBottom: true,
- forcedByConfig: windowConfig.homeButton === true,
- }),
- }),
- }
- }, [host, bridgeId])
+ const initialEntry = useMemo(
+ () => makeLaunchPageEntry(host, bridgeId),
+ [host, bridgeId],
+ )
const [{ shell, tabBar }, setState] = useState(() => ({
shell: makeInitialShellState(initialEntry),
@@ -155,6 +144,16 @@ export function MiniAppFrame({
const stateRef = useRef({ shell, tabBar })
const applySideEffects = useCallback((effects: SideEffect[]) => {
+ // 几何要先于 pageShow 到达主进程:模拟器里 `getSystemInfoSync` 读的是主进程缓存的 hostEnv 快照,`onShow` 里同步读到的必须已经是落地页自己的尺寸。
+ // 三端 native 不需要这一步,它们的同步接口每次都现读窗口。
+ //
+ // 这条上报因此排在 pageShow 之前,而 service 的 pageResize 不能因为「这一页还没 show」就把它丢掉——收件人在 16ms 合并窗结算时才定,那时 pageShow 早已送达(见 fe/packages/service/src/core/runtime.js 的 pageResize/settleResize)。
+ const currentShell = stateRef.current.shell
+ onLayoutCommit?.({
+ top: currentShell.stack[currentShell.stack.length - 1],
+ mounted: enumerateMounted(currentShell),
+ tabBarVisible: stateRef.current.tabBar.visible,
+ })
for (const effect of effects) {
if (effect.kind === 'lifecycle') {
host.notifyLifecycle(effect.bridgeId, effect.event)
@@ -162,7 +161,7 @@ export function MiniAppFrame({
host.closePage(effect.bridgeId)
}
}
- }, [host])
+ }, [host, onLayoutCommit])
// ── NavigationBar dynamic updates ──────────────────────────────────────────
useEffect(() => {
@@ -183,12 +182,21 @@ export function MiniAppFrame({
// ── TabBar dynamic API ────────────────────────────────────────────────────
useEffect(() => {
const listener = (payload: TabActionPayload) => {
- const next = applyTabAction(stateRef.current.tabBar, {
+ const previous = stateRef.current.tabBar
+ const next = applyTabAction(previous, {
kind: 'apply',
name: payload.name,
params: payload.params,
})
commitTabBar(stateRef, setState, next.state)
+ if (previous.visible !== next.state.visible) {
+ const currentShell = stateRef.current.shell
+ onLayoutCommit?.({
+ top: currentShell.stack[currentShell.stack.length - 1],
+ mounted: enumerateMounted(currentShell),
+ tabBarVisible: next.state.visible,
+ })
+ }
host.notifyNavCallback({
ok: next.ok,
errMsg: next.errMsg,
@@ -196,7 +204,7 @@ export function MiniAppFrame({
})
}
return host.onSessionEvent(E.TAB_ACTION, listener)
- }, [host])
+ }, [host, onLayoutCommit])
// ── Routing controller (navigateTo / Back / redirectTo / reLaunch / switchTab / Home) ─
// Every routing operation opens its page asynchronously and only then reads
@@ -314,6 +322,9 @@ export function MiniAppFrame({
// ── Rendering ─────────────────────────────────────────────────────────────
const top = shell.stack[shell.stack.length - 1]
const mounted = enumerateMounted(shell)
+ useEffect(() => {
+ onLayoutState?.({ top, mounted, tabBarVisible: tabBar.visible })
+ }, [mounted, onLayoutState, tabBar.visible, top])
const handleMore = useCallback(() => {
onMore?.({
appId: host.appId,
@@ -345,6 +356,12 @@ export function MiniAppFrame({
host.notifyActivePage(top.bridgeId)
}, [host, top.bridgeId])
+ // The launch page is the one page no routing reduction ever installs, so it is also the one page nothing else would announce as visible — every other top gets its pageShow from the reducers (see showTop in page-stack-controller).
+ // Declared after the layout effects above so the page's geometry is published before its onShow runs, same order routing transitions get from applySideEffects.
+ useEffect(() => {
+ host.notifyLifecycle(initialEntry.bridgeId, 'pageShow')
+ }, [host, initialEntry.bridgeId])
+
return (
<>
{statusBar?.({ textStyle: top.navBar.textStyle })}
diff --git a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-host.ts b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-host.ts
index f2966107..7c446eab 100644
--- a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-host.ts
+++ b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-host.ts
@@ -13,6 +13,7 @@
* not by importing anything from the runtime.
*/
import type {
+ ApiResponsePayload,
NavCallbackPayload,
PageLifecycleEvent,
PageOpenResult,
@@ -20,6 +21,7 @@ import type {
PageWindowConfig,
TabBarConfig,
} from '../shared/bridge-channels.js'
+import type { PageResizePayload } from '../shared/page-orientation.js'
export interface MiniAppHost {
readonly appId: string
@@ -55,6 +57,9 @@ export interface MiniAppHost {
closePage(bridgeId: string): void
notifyLifecycle(bridgeId: string, event: PageLifecycleEvent): void
notifyNavCallback(payload: Omit): void
+ notifyApiResponse?(payload: Omit): void
+ /** Publish the visible top page's authoritative window geometry. */
+ notifyResize?(payload: PageResizePayload): void
/** Which page is the visible top of stack — panels and automation target it. */
notifyActivePage(bridgeId: string): void
/** The full ordered stack, bottom→top, on every stack change. */
diff --git a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-routing.ts b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-routing.ts
index cdeb6217..a5401262 100644
--- a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-routing.ts
+++ b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-routing.ts
@@ -12,8 +12,8 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import type { NavActionPayload } from '../shared/bridge-channels.js'
import type { MiniAppHost } from './miniapp-host.js'
+import { navBarFromConfig } from './navigation-bar-config.js'
import {
- navBarFromConfig,
normalizePath,
parseUrl,
reduceNavigateBack,
@@ -101,6 +101,20 @@ function makePageEntry(
}
}
+/**
+ * The launch page's PageEntry: the stack bottom the host already spawned before MiniAppFrame mounted.
+ * Exported because the embedding device host has to seed its own mirror of the frame's layout from the same values — it publishes the page's window geometry, and `navigationStyle` decides whether a navigation bar is reserved out of that window.
+ * A second hand-written seed drifts.
+ */
+export function makeLaunchPageEntry(host: MiniAppHost, bridgeId: string): PageEntry {
+ const pagePath = normalizePath(host.pagePath)
+ const windowConfig = host.rootWindowConfig ?? {}
+ const isTab = !!host.getTabBarConfig()?.list.some(
+ item => normalizePath(item.pagePath) === pagePath,
+ )
+ return makePageEntry(host, { bridgeId, pagePath, isTab, windowConfig }, { ...host.query }, true)
+}
+
export async function doNavigateTo(
host: MiniAppHost,
ref: StateRef,
diff --git a/packages/dimina-electron-runtime/src/simulator-ui/navigate-home-reduce.test.ts b/packages/dimina-electron-runtime/src/simulator-ui/navigate-home-reduce.test.ts
index ce44b066..705ec33c 100644
--- a/packages/dimina-electron-runtime/src/simulator-ui/navigate-home-reduce.test.ts
+++ b/packages/dimina-electron-runtime/src/simulator-ui/navigate-home-reduce.test.ts
@@ -151,9 +151,9 @@ describe('reduceNavigateHomeToTab — deep link into a non-tab page, no tab visi
expect(closedIds(effects)).toEqual(['d'])
})
- it('emits no pageShow for a freshly opened root, whose renderer reports its own', () => {
+ it('shows the freshly opened root: nothing else tells the service it is visible', () => {
const { effects } = reduceNavigateHomeToTab(state, TAB_A, tabA)
- expect(lifecycleIds(effects, 'pageShow')).toEqual([])
+ expect(lifecycleIds(effects, 'pageShow')).toEqual(['a-root'])
})
})
diff --git a/packages/dimina-electron-runtime/src/simulator-ui/navigate-home.ts b/packages/dimina-electron-runtime/src/simulator-ui/navigate-home.ts
index b63bc71d..61efef84 100644
--- a/packages/dimina-electron-runtime/src/simulator-ui/navigate-home.ts
+++ b/packages/dimina-electron-runtime/src/simulator-ui/navigate-home.ts
@@ -115,9 +115,8 @@ export function reduceNavigateHomeToTab(
if (prevTop && prevTop.bridgeId !== homeRoot.bridgeId && survivors.has(prevTop.bridgeId)) {
effects.push({ kind: 'lifecycle', bridgeId: prevTop.bridgeId, event: 'pageHide' })
}
- if (cachedRoot) {
- // Restored from cache — a freshly opened page gets its own lifecycle from
- // the renderer init path.
+ if (!prevTop || prevTop.bridgeId !== homeRoot.bridgeId) {
+ // Restored from cache or opened for this transition — either way the home root is the visible top now and the service only learns that from a pageShow (see page-stack-controller's showTop).
effects.push({ kind: 'lifecycle', bridgeId: homeRoot.bridgeId, event: 'pageShow' })
}
diff --git a/packages/dimina-electron-runtime/src/simulator-ui/navigation-bar-config.test.ts b/packages/dimina-electron-runtime/src/simulator-ui/navigation-bar-config.test.ts
new file mode 100644
index 00000000..b2bba838
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/simulator-ui/navigation-bar-config.test.ts
@@ -0,0 +1,133 @@
+/**
+ * `NavigationBarState` producers: what a page's merged window config implies, and what the dynamic `wx.setNavigationBar*` / `wx.hideHomeButton` calls do to it afterwards.
+ */
+import { describe, it, expect } from 'vitest'
+import { applyColorMutation, navBarFromConfig, reduceNavBar } from './navigation-bar-config.js'
+import { makeDefaultNavigationBarState, type NavigationBarState } from './navigation-bar.js'
+
+function makeNavBar(overrides: Partial = {}): NavigationBarState {
+ return makeDefaultNavigationBarState({
+ title: '',
+ backgroundColor: '#000000',
+ textStyle: 'white',
+ style: 'default',
+ homeButtonVisible: false,
+ loading: false,
+ ...overrides,
+ })
+}
+
+// ── navBarFromConfig ─────────────────────────────────────────────────────
+
+describe('navBarFromConfig', () => {
+ it('falls back to defaults (#ffffff bg, black text, default style) and uses fallback title when config is empty', () => {
+ const state = navBarFromConfig({}, 'my-app-id')
+ expect(state).toMatchObject({
+ title: 'my-app-id',
+ backgroundColor: '#ffffff',
+ textStyle: 'black',
+ style: 'default',
+ homeButtonVisible: false,
+ })
+ })
+
+ it('uses navigationBarTitleText when supplied (overriding the fallback)', () => {
+ expect(navBarFromConfig({ navigationBarTitleText: 'Hello' }, 'fallback').title).toBe('Hello')
+ })
+
+ it('respects navigationBarTextStyle: white', () => {
+ expect(navBarFromConfig({ navigationBarTextStyle: 'white' }, 'x').textStyle).toBe('white')
+ })
+
+ it('respects a custom navigationBarBackgroundColor', () => {
+ expect(navBarFromConfig({ navigationBarBackgroundColor: '#abcdef' }, 'x').backgroundColor).toBe('#abcdef')
+ })
+
+ it("respects navigationStyle: 'custom'", () => {
+ expect(navBarFromConfig({ navigationStyle: 'custom' }, 'x').style).toBe('custom')
+ })
+
+ it('shows the home button only when config.homeButton === true (strict equality)', () => {
+ expect(navBarFromConfig({ homeButton: true }, 'x').homeButtonVisible).toBe(true)
+ // Defensive: non-true truthy values are rejected.
+ expect(navBarFromConfig({ homeButton: 1 as unknown as boolean }, 'x').homeButtonVisible).toBe(false)
+ })
+})
+
+// ── reduceNavBar ─────────────────────────────────────────────────────────
+
+describe('reduceNavBar', () => {
+ it('setNavigationBarTitle updates the title field', () => {
+ const next = reduceNavBar(makeNavBar({ title: 'old' }), 'setNavigationBarTitle', { title: 'new' })
+ expect(next.title).toBe('new')
+ })
+
+ it('setNavigationBarColor delegates to applyColorMutation (frontColor white → textStyle white)', () => {
+ const next = reduceNavBar(makeNavBar({ textStyle: 'black' }), 'setNavigationBarColor', { frontColor: '#ffffff' })
+ expect(next.textStyle).toBe('white')
+ })
+
+ it('showNavigationBarLoading flips loading=true', () => {
+ expect(reduceNavBar(makeNavBar({ loading: false }), 'showNavigationBarLoading', {}).loading).toBe(true)
+ })
+
+ it('hideNavigationBarLoading flips loading=false', () => {
+ expect(reduceNavBar(makeNavBar({ loading: true }), 'hideNavigationBarLoading', {}).loading).toBe(false)
+ })
+
+ it('hideHomeButton flips homeButtonVisible=false', () => {
+ expect(reduceNavBar(makeNavBar({ homeButtonVisible: true }), 'hideHomeButton', {}).homeButtonVisible).toBe(false)
+ })
+
+ it('returns the same state reference for unknown API names (no mutation, no throw)', () => {
+ const prev = makeNavBar({ title: 'unchanged' })
+ const next = reduceNavBar(prev, 'wxBananaApi', {})
+ expect(next).toBe(prev)
+ })
+})
+
+// ── applyColorMutation ────────────────────────────────────────────────────
+
+describe('applyColorMutation', () => {
+ it('frontColor #ffffff (any case) sets textStyle=white', () => {
+ expect(applyColorMutation(makeNavBar({ textStyle: 'black' }), { frontColor: '#FFFFFF' }).textStyle).toBe('white')
+ })
+
+ it('frontColor #000000 sets textStyle=black', () => {
+ expect(applyColorMutation(makeNavBar({ textStyle: 'white' }), { frontColor: '#000000' }).textStyle).toBe('black')
+ })
+
+ it('illegal frontColor (e.g. #ff0000) keeps the previous textStyle', () => {
+ const prev = makeNavBar({ textStyle: 'white' })
+ expect(applyColorMutation(prev, { frontColor: '#ff0000' }).textStyle).toBe('white')
+ })
+
+ it('passes through backgroundColor when supplied as a string', () => {
+ expect(applyColorMutation(makeNavBar(), { backgroundColor: '#123456' }).backgroundColor).toBe('#123456')
+ })
+
+ it('animation: whitelisted timingFunc (easeIn) is preserved with duration in ms', () => {
+ const next = applyColorMutation(makeNavBar(), {
+ animation: { duration: 250, timingFunc: 'easeIn' },
+ })
+ expect(next.colorAnimation).toEqual({ durationMs: 250, timingFunc: 'easeIn' })
+ })
+
+ it("animation: non-whitelisted timingFunc (e.g. 'bounce') falls back to 'linear'", () => {
+ const next = applyColorMutation(makeNavBar(), {
+ animation: { duration: 100, timingFunc: 'bounce' },
+ })
+ expect(next.colorAnimation?.timingFunc).toBe('linear')
+ })
+
+ it('animation: NaN duration clamps to 0 (defensive)', () => {
+ const next = applyColorMutation(makeNavBar(), {
+ animation: { duration: Number.NaN, timingFunc: 'linear' },
+ })
+ expect(next.colorAnimation?.durationMs).toBe(0)
+ })
+
+ it('returns undefined colorAnimation when no animation field is supplied', () => {
+ expect(applyColorMutation(makeNavBar(), {}).colorAnimation).toBeUndefined()
+ })
+})
diff --git a/packages/dimina-electron-runtime/src/simulator-ui/navigation-bar-config.ts b/packages/dimina-electron-runtime/src/simulator-ui/navigation-bar-config.ts
new file mode 100644
index 00000000..ebdf0d6a
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/simulator-ui/navigation-bar-config.ts
@@ -0,0 +1,95 @@
+/**
+ * Everything that produces a `NavigationBarState`: the initial state a page's merged window config implies, and the dynamic `wx.setNavigationBar*` / `wx.hideHomeButton` mutations applied over it afterwards.
+ *
+ * Kept apart from the page-stack reducers — these touch one page's bar, never the stack — and from `navigation-bar.tsx`, which only renders the state.
+ */
+import type { PageWindowConfig } from '../shared/bridge-channels.js'
+import { makeDefaultNavigationBarState, type NavigationBarState } from './navigation-bar.js'
+
+/**
+ * Build the initial NavigationBar state from a page's merged window config (app-config.json `window` ∪ page-level overrides).
+ * The fallback title is used when `navigationBarTitleText` is unset (typically the appId). `opts.homeButtonVisible` sets the home button verbatim — callers that know the page's stack position pass the `shouldShowHomeButton` verdict here so the home/tab exclusions apply.
+ * Without it only the page config speaks.
+ */
+export function navBarFromConfig(
+ config: PageWindowConfig,
+ fallbackTitle: string,
+ opts?: { homeButtonVisible?: boolean },
+): NavigationBarState {
+ const background = (config.navigationBarBackgroundColor as string | undefined) ?? '#ffffff'
+ const text = (config.navigationBarTextStyle as 'black' | 'white' | undefined) ?? 'black'
+ const style = (config.navigationStyle as 'default' | 'custom' | undefined) ?? 'default'
+ const title = (config.navigationBarTitleText as string | undefined) ?? fallbackTitle
+ const homeButtonVisible = opts?.homeButtonVisible ?? (config.homeButton === true)
+ return makeDefaultNavigationBarState({
+ title,
+ backgroundColor: background,
+ textStyle: text,
+ style,
+ homeButtonVisible,
+ })
+}
+
+/**
+ * Reduce one of the dynamic NavigationBar APIs (setNavigationBarTitle / setNavigationBarColor / show|hideNavigationBarLoading / hideHomeButton) over a page's nav-bar state.
+ * Unknown names fall through to `prev`.
+ */
+export function reduceNavBar(
+ prev: NavigationBarState,
+ name: string,
+ params: Record,
+): NavigationBarState {
+ switch (name) {
+ case 'setNavigationBarTitle':
+ return { ...prev, title: typeof params.title === 'string' ? params.title : prev.title }
+ case 'setNavigationBarColor':
+ return applyColorMutation(prev, params)
+ case 'showNavigationBarLoading':
+ return { ...prev, loading: true }
+ case 'hideNavigationBarLoading':
+ return { ...prev, loading: false }
+ case 'hideHomeButton':
+ return { ...prev, homeButtonVisible: false }
+ default:
+ return prev
+ }
+}
+
+const ALLOWED_TIMING_FUNCS = ['linear', 'easeIn', 'easeOut', 'easeInOut'] as const
+type TimingFunc = typeof ALLOWED_TIMING_FUNCS[number]
+
+/**
+ * Apply `wx.setNavigationBarColor` to a navBar state:
+ * - frontColor must be `#ffffff` or `#000000` (WeChat constraint); other
+ * values are ignored and previous textStyle is preserved.
+ * - backgroundColor passes through if it's a string.
+ * - animation `{ duration, timingFunc }` is normalized to ms + a whitelisted
+ * timingFunc, defaulting to 0ms / linear when missing or invalid.
+ */
+export function applyColorMutation(
+ prev: NavigationBarState,
+ params: Record,
+): NavigationBarState {
+ const front = typeof params.frontColor === 'string' ? params.frontColor.toLowerCase() : undefined
+ const textStyle = front === '#ffffff' ? 'white' : front === '#000000' ? 'black' : prev.textStyle
+ const background = typeof params.backgroundColor === 'string' ? params.backgroundColor : prev.backgroundColor
+
+ const animation = (() => {
+ const raw = params.animation
+ if (!raw || typeof raw !== 'object') return undefined
+ const obj = raw as Record
+ const duration = typeof obj.duration === 'number' && Number.isFinite(obj.duration) ? Math.max(0, obj.duration) : 0
+ const timing = typeof obj.timingFunc === 'string' ? obj.timingFunc : 'linear'
+ const timingFunc: TimingFunc = (ALLOWED_TIMING_FUNCS as readonly string[]).includes(timing)
+ ? (timing as TimingFunc)
+ : 'linear'
+ return { durationMs: duration, timingFunc }
+ })()
+
+ return {
+ ...prev,
+ textStyle,
+ backgroundColor: background,
+ colorAnimation: animation,
+ }
+}
diff --git a/packages/dimina-electron-runtime/src/simulator-ui/page-stack-controller.test.ts b/packages/dimina-electron-runtime/src/simulator-ui/page-stack-controller.test.ts
index c03c4792..60258adf 100644
--- a/packages/dimina-electron-runtime/src/simulator-ui/page-stack-controller.test.ts
+++ b/packages/dimina-electron-runtime/src/simulator-ui/page-stack-controller.test.ts
@@ -1,14 +1,11 @@
import { describe, it, expect } from 'vitest'
import {
- applyColorMutation,
enumerateMounted,
makeInitialShellState,
mutatePageNavBar,
- navBarFromConfig,
normalizePath,
pageBackgroundColor,
parseUrl,
- reduceNavBar,
reduceNavigateBack,
reduceNavigateTo,
reduceReLaunch,
@@ -17,19 +14,7 @@ import {
type PageEntry,
type ShellState,
} from './page-stack-controller.js'
-import { makeDefaultNavigationBarState, type NavigationBarState } from './navigation-bar.js'
-
-function makeNavBar(overrides: Partial = {}): NavigationBarState {
- return makeDefaultNavigationBarState({
- title: '',
- backgroundColor: '#000000',
- textStyle: 'white',
- style: 'default',
- homeButtonVisible: false,
- loading: false,
- ...overrides,
- })
-}
+import { makeDefaultNavigationBarState } from './navigation-bar.js'
// ── helpers ───────────────────────────────────────────────────────────────
@@ -133,7 +118,10 @@ describe('reduceNavigateTo', () => {
expect(bridgeIds(next.stack)).toEqual([tabA.bridgeId, page1.bridgeId])
expect(bridgeIds(next.tabStacks[tabA.pagePath])).toEqual([tabA.bridgeId, page1.bridgeId])
- expect(effects).toEqual([{ kind: 'lifecycle', bridgeId: tabA.bridgeId, event: 'pageHide' }])
+ expect(effects).toEqual([
+ { kind: 'lifecycle', bridgeId: tabA.bridgeId, event: 'pageHide' },
+ { kind: 'lifecycle', bridgeId: page1.bridgeId, event: 'pageShow' },
+ ])
})
})
@@ -374,43 +362,6 @@ describe('normalizePath', () => {
})
})
-// ── navBarFromConfig ─────────────────────────────────────────────────────
-
-describe('navBarFromConfig', () => {
- it('falls back to defaults (#ffffff bg, black text, default style) and uses fallback title when config is empty', () => {
- const state = navBarFromConfig({}, 'my-app-id')
- expect(state).toMatchObject({
- title: 'my-app-id',
- backgroundColor: '#ffffff',
- textStyle: 'black',
- style: 'default',
- homeButtonVisible: false,
- })
- })
-
- it('uses navigationBarTitleText when supplied (overriding the fallback)', () => {
- expect(navBarFromConfig({ navigationBarTitleText: 'Hello' }, 'fallback').title).toBe('Hello')
- })
-
- it('respects navigationBarTextStyle: white', () => {
- expect(navBarFromConfig({ navigationBarTextStyle: 'white' }, 'x').textStyle).toBe('white')
- })
-
- it('respects a custom navigationBarBackgroundColor', () => {
- expect(navBarFromConfig({ navigationBarBackgroundColor: '#abcdef' }, 'x').backgroundColor).toBe('#abcdef')
- })
-
- it("respects navigationStyle: 'custom'", () => {
- expect(navBarFromConfig({ navigationStyle: 'custom' }, 'x').style).toBe('custom')
- })
-
- it('shows the home button only when config.homeButton === true (strict equality)', () => {
- expect(navBarFromConfig({ homeButton: true }, 'x').homeButtonVisible).toBe(true)
- // Defensive: non-true truthy values are rejected.
- expect(navBarFromConfig({ homeButton: 1 as unknown as boolean }, 'x').homeButtonVisible).toBe(false)
- })
-})
-
// ── pageBackgroundColor ────────────────────────────────────────────────────
describe('pageBackgroundColor', () => {
@@ -423,84 +374,6 @@ describe('pageBackgroundColor', () => {
})
})
-// ── reduceNavBar ─────────────────────────────────────────────────────────
-
-describe('reduceNavBar', () => {
- it('setNavigationBarTitle updates the title field', () => {
- const next = reduceNavBar(makeNavBar({ title: 'old' }), 'setNavigationBarTitle', { title: 'new' })
- expect(next.title).toBe('new')
- })
-
- it('setNavigationBarColor delegates to applyColorMutation (frontColor white → textStyle white)', () => {
- const next = reduceNavBar(makeNavBar({ textStyle: 'black' }), 'setNavigationBarColor', { frontColor: '#ffffff' })
- expect(next.textStyle).toBe('white')
- })
-
- it('showNavigationBarLoading flips loading=true', () => {
- expect(reduceNavBar(makeNavBar({ loading: false }), 'showNavigationBarLoading', {}).loading).toBe(true)
- })
-
- it('hideNavigationBarLoading flips loading=false', () => {
- expect(reduceNavBar(makeNavBar({ loading: true }), 'hideNavigationBarLoading', {}).loading).toBe(false)
- })
-
- it('hideHomeButton flips homeButtonVisible=false', () => {
- expect(reduceNavBar(makeNavBar({ homeButtonVisible: true }), 'hideHomeButton', {}).homeButtonVisible).toBe(false)
- })
-
- it('returns the same state reference for unknown API names (no mutation, no throw)', () => {
- const prev = makeNavBar({ title: 'unchanged' })
- const next = reduceNavBar(prev, 'wxBananaApi', {})
- expect(next).toBe(prev)
- })
-})
-
-// ── applyColorMutation ────────────────────────────────────────────────────
-
-describe('applyColorMutation', () => {
- it('frontColor #ffffff (any case) sets textStyle=white', () => {
- expect(applyColorMutation(makeNavBar({ textStyle: 'black' }), { frontColor: '#FFFFFF' }).textStyle).toBe('white')
- })
-
- it('frontColor #000000 sets textStyle=black', () => {
- expect(applyColorMutation(makeNavBar({ textStyle: 'white' }), { frontColor: '#000000' }).textStyle).toBe('black')
- })
-
- it('illegal frontColor (e.g. #ff0000) keeps the previous textStyle', () => {
- const prev = makeNavBar({ textStyle: 'white' })
- expect(applyColorMutation(prev, { frontColor: '#ff0000' }).textStyle).toBe('white')
- })
-
- it('passes through backgroundColor when supplied as a string', () => {
- expect(applyColorMutation(makeNavBar(), { backgroundColor: '#123456' }).backgroundColor).toBe('#123456')
- })
-
- it('animation: whitelisted timingFunc (easeIn) is preserved with duration in ms', () => {
- const next = applyColorMutation(makeNavBar(), {
- animation: { duration: 250, timingFunc: 'easeIn' },
- })
- expect(next.colorAnimation).toEqual({ durationMs: 250, timingFunc: 'easeIn' })
- })
-
- it("animation: non-whitelisted timingFunc (e.g. 'bounce') falls back to 'linear'", () => {
- const next = applyColorMutation(makeNavBar(), {
- animation: { duration: 100, timingFunc: 'bounce' },
- })
- expect(next.colorAnimation?.timingFunc).toBe('linear')
- })
-
- it('animation: NaN duration clamps to 0 (defensive)', () => {
- const next = applyColorMutation(makeNavBar(), {
- animation: { duration: Number.NaN, timingFunc: 'linear' },
- })
- expect(next.colorAnimation?.durationMs).toBe(0)
- })
-
- it('returns undefined colorAnimation when no animation field is supplied', () => {
- expect(applyColorMutation(makeNavBar(), {}).colorAnimation).toBeUndefined()
- })
-})
-
// ── mutatePageNavBar ─────────────────────────────────────────────────────
describe('mutatePageNavBar', () => {
diff --git a/packages/dimina-electron-runtime/src/simulator-ui/page-stack-controller.ts b/packages/dimina-electron-runtime/src/simulator-ui/page-stack-controller.ts
index 8346cb88..56b26dd9 100644
--- a/packages/dimina-electron-runtime/src/simulator-ui/page-stack-controller.ts
+++ b/packages/dimina-electron-runtime/src/simulator-ui/page-stack-controller.ts
@@ -12,7 +12,7 @@
* unit-test it without faking React / IPC.
*/
import type { PageWindowConfig } from '../shared/bridge-channels.js'
-import { makeDefaultNavigationBarState, type NavigationBarState } from './navigation-bar.js'
+import type { NavigationBarState } from './navigation-bar.js'
export interface PageEntry {
bridgeId: string
@@ -38,6 +38,17 @@ export type SideEffect =
| { kind: 'lifecycle'; bridgeId: string; event: 'pageShow' | 'pageHide' | 'pageUnload' }
| { kind: 'closePage'; bridgeId: string }
+/**
+ * Whoever becomes the visible top gets `pageShow` — a page opened for this very transition included.
+ * Nothing else in this container announces a page's visibility: the render host reports resources and readiness, never that its page is on screen, and the service treats a page as hidden until a `pageShow` says otherwise (`Runtime.pageStates[bridgeId].shown`).
+ * Without one the page's `onShow` never runs and everything the service gates on visibility — `Page.onResize` among them — is dropped for the life of that page.
+ *
+ * Re-announcing a page that is already shown is inert (the service's `pageShow` returns early when `shown`), so callers do not have to know whether the top they are installing is fresh or restored from a tab cache.
+ */
+function showTop(bridgeId: string): SideEffect {
+ return { kind: 'lifecycle', bridgeId, event: 'pageShow' }
+}
+
export interface UrlParts {
pagePath: string
query: Record
@@ -159,12 +170,12 @@ export function reduceNavigateTo(
? { ...state.tabStacks, [state.currentTabPath]: nextStack }
: state.tabStacks,
}
- return {
- next,
- effects: prevTop
- ? [{ kind: 'lifecycle', bridgeId: prevTop.bridgeId, event: 'pageHide' }]
- : [],
+ const effects: SideEffect[] = []
+ if (prevTop) {
+ effects.push({ kind: 'lifecycle', bridgeId: prevTop.bridgeId, event: 'pageHide' })
}
+ effects.push(showTop(newEntry.bridgeId))
+ return { next, effects }
}
export function reduceNavigateBack(
@@ -226,6 +237,7 @@ export function reduceRedirectTo(
effects.push({ kind: 'lifecycle', bridgeId: prevTop.bridgeId, event: 'pageUnload' })
effects.push({ kind: 'closePage', bridgeId: prevTop.bridgeId })
}
+ effects.push(showTop(newEntry.bridgeId))
return { next, effects }
}
@@ -259,6 +271,7 @@ export function reduceReLaunch(
effects.push({ kind: 'lifecycle', bridgeId, event: 'pageUnload' })
effects.push({ kind: 'closePage', bridgeId })
}
+ effects.push(showTop(newEntry.bridgeId))
return { next, effects }
}
@@ -269,7 +282,7 @@ export function reduceReLaunch(
* 2. If the target tab already has a saved substack, restore it as the
* visible stack. Otherwise build a fresh single-page stack with the
* newly-opened tab entry passed in by the caller.
- * 3. Lifecycle: pageHide prev top, pageShow restored top.
+ * 3. Lifecycle: pageHide prev top, pageShow the new top (restored or fresh).
* 4. Every substack survives, so a page held by any tab is never torn down.
* A page held by none — the visible page of a session with no active tab —
* belongs to nothing the switch preserves and gets pageUnload + closePage.
@@ -318,10 +331,8 @@ export function reduceSwitchTab(
// preserves. Pages still held by a tab substack survive untouched: keeping
// them is the per-tab cache semantics this shell mirrors from iOS/Harmony.
effects.push(...teardownDropped(state, next))
- if (!freshlyOpenedEntry) {
- // Restored from cache — emit pageShow. (Newly-opened pages get their
- // own lifecycle from the renderer init path.)
- effects.push({ kind: 'lifecycle', bridgeId: newTop.bridgeId, event: 'pageShow' })
+ if (!prevTop || prevTop.bridgeId !== newTop.bridgeId) {
+ effects.push(showTop(newTop.bridgeId))
}
return { next, effects }
}
@@ -369,7 +380,7 @@ export function enumerateMounted(state: ShellState): MountedEntry[] {
return Array.from(byBridgeId.values())
}
-// ── NavigationBar derivations ───────────────────────────────────────────
+// ── Page surface derivations ────────────────────────────────────────────
/**
* The page's own body background — WeChat/Android/Harmony parity: primes the
@@ -383,98 +394,6 @@ export function enumerateMounted(state: ShellState): MountedEntry[] {
export function pageBackgroundColor(config: PageWindowConfig): string {
return (config.backgroundColor as string | undefined) ?? '#ffffff'
}
-/**
- * Build the initial NavigationBar state from a page's merged window config
- * (app-config.json `window` ∪ page-level overrides). The fallback title is
- * used when `navigationBarTitleText` is unset (typically the appId).
- * `opts.homeButtonVisible` sets the home button verbatim — callers that know
- * the page's stack position pass the `shouldShowHomeButton` verdict here so
- * the home/tab exclusions apply. Without it only the page config speaks.
- */
-export function navBarFromConfig(
- config: PageWindowConfig,
- fallbackTitle: string,
- opts?: { homeButtonVisible?: boolean },
-): NavigationBarState {
- const background = (config.navigationBarBackgroundColor as string | undefined) ?? '#ffffff'
- const text = (config.navigationBarTextStyle as 'black' | 'white' | undefined) ?? 'black'
- const style = (config.navigationStyle as 'default' | 'custom' | undefined) ?? 'default'
- const title = (config.navigationBarTitleText as string | undefined) ?? fallbackTitle
- const homeButtonVisible = opts?.homeButtonVisible ?? (config.homeButton === true)
- return makeDefaultNavigationBarState({
- title,
- backgroundColor: background,
- textStyle: text,
- style,
- homeButtonVisible,
- })
-}
-
-/**
- * Reduce one of the dynamic NavigationBar APIs (setNavigationBarTitle /
- * setNavigationBarColor / show|hideNavigationBarLoading / hideHomeButton)
- * over a page's nav-bar state. Unknown names fall through to `prev`.
- */
-export function reduceNavBar(
- prev: NavigationBarState,
- name: string,
- params: Record,
-): NavigationBarState {
- switch (name) {
- case 'setNavigationBarTitle':
- return { ...prev, title: typeof params.title === 'string' ? params.title : prev.title }
- case 'setNavigationBarColor':
- return applyColorMutation(prev, params)
- case 'showNavigationBarLoading':
- return { ...prev, loading: true }
- case 'hideNavigationBarLoading':
- return { ...prev, loading: false }
- case 'hideHomeButton':
- return { ...prev, homeButtonVisible: false }
- default:
- return prev
- }
-}
-
-const ALLOWED_TIMING_FUNCS = ['linear', 'easeIn', 'easeOut', 'easeInOut'] as const
-type TimingFunc = typeof ALLOWED_TIMING_FUNCS[number]
-
-/**
- * Apply `wx.setNavigationBarColor` to a navBar state:
- * - frontColor must be `#ffffff` or `#000000` (WeChat constraint); other
- * values are ignored and previous textStyle is preserved.
- * - backgroundColor passes through if it's a string.
- * - animation `{ duration, timingFunc }` is normalized to ms + a whitelisted
- * timingFunc, defaulting to 0ms / linear when missing or invalid.
- */
-export function applyColorMutation(
- prev: NavigationBarState,
- params: Record,
-): NavigationBarState {
- const front = typeof params.frontColor === 'string' ? params.frontColor.toLowerCase() : undefined
- const textStyle = front === '#ffffff' ? 'white' : front === '#000000' ? 'black' : prev.textStyle
- const background = typeof params.backgroundColor === 'string' ? params.backgroundColor : prev.backgroundColor
-
- const animation = (() => {
- const raw = params.animation
- if (!raw || typeof raw !== 'object') return undefined
- const obj = raw as Record
- const duration = typeof obj.duration === 'number' && Number.isFinite(obj.duration) ? Math.max(0, obj.duration) : 0
- const timing = typeof obj.timingFunc === 'string' ? obj.timingFunc : 'linear'
- const timingFunc: TimingFunc = (ALLOWED_TIMING_FUNCS as readonly string[]).includes(timing)
- ? (timing as TimingFunc)
- : 'linear'
- return { durationMs: duration, timingFunc }
- })()
-
- return {
- ...prev,
- textStyle,
- backgroundColor: background,
- colorAnimation: animation,
- }
-}
-
// ── NavigationBar mutator (shared by IPC handler) ───────────────────────
/**
diff --git a/packages/dimina-electron-runtime/src/simulator-ui/tab-bar-state.test.ts b/packages/dimina-electron-runtime/src/simulator-ui/tab-bar-state.test.ts
index 8f781475..360a125e 100644
--- a/packages/dimina-electron-runtime/src/simulator-ui/tab-bar-state.test.ts
+++ b/packages/dimina-electron-runtime/src/simulator-ui/tab-bar-state.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import {
+ changesReportedGeometry,
makeInitialTabBarState,
applyTabAction,
} from './tab-bar-state.js'
@@ -515,3 +516,53 @@ describe('applyTabAction — immutability', () => {
expect(prev).toEqual(snapshot)
})
})
+
+// ---- changesReportedGeometry ------------------------------------------------
+
+/**
+ * The shell republishes the top page's geometry — and delays the caller's ack until it has — exactly when this predicate says the change moves it.
+ * The judgement lives here rather than in a list of action names at the call site, so a new action can never be forgotten.
+ */
+describe('changesReportedGeometry', () => {
+ const base = makeInitialTabBarState(makeConfig(3))
+
+ it('is true when the bar leaves the layout flow', () => {
+ const hidden = applyTabAction(base, { kind: 'apply', name: 'hideTabBar', params: {} }).state
+ expect(changesReportedGeometry(base, hidden)).toBe(true)
+ })
+
+ it('is true when the bar comes back into the layout flow', () => {
+ const hidden = applyTabAction(base, { kind: 'apply', name: 'hideTabBar', params: {} }).state
+ const shown = applyTabAction(hidden, { kind: 'apply', name: 'showTabBar', params: {} }).state
+ expect(changesReportedGeometry(hidden, shown)).toBe(true)
+ })
+
+ it('is false for text, icon, style, badge and red-dot edits', () => {
+ const edits: Array<[string, Record]> = [
+ ['setTabBarItem', { index: 0, text: 'renamed' }],
+ ['setTabBarStyle', { color: '#123456' }],
+ ['setTabBarBadge', { index: 1, text: '9' }],
+ ['removeTabBarBadge', { index: 1 }],
+ ['showTabBarRedDot', { index: 2 }],
+ ['hideTabBarRedDot', { index: 2 }],
+ ]
+ for (const [name, params] of edits) {
+ const next = applyTabAction(base, {
+ kind: 'apply',
+ name: name as 'setTabBarItem',
+ params,
+ }).state
+ expect(changesReportedGeometry(base, next), `${name} keeps the bar in flow`).toBe(false)
+ }
+ })
+
+ it('is false for a rejected action, which leaves the state untouched', () => {
+ const rejected = applyTabAction(base, {
+ kind: 'apply',
+ name: 'hideTabBarRedDot',
+ params: { index: 99 },
+ })
+ expect(rejected.ok).toBe(false)
+ expect(changesReportedGeometry(base, rejected.state)).toBe(false)
+ })
+})
diff --git a/packages/dimina-electron-runtime/src/simulator-ui/tab-bar-state.ts b/packages/dimina-electron-runtime/src/simulator-ui/tab-bar-state.ts
index 08ca59ae..e439b584 100644
--- a/packages/dimina-electron-runtime/src/simulator-ui/tab-bar-state.ts
+++ b/packages/dimina-electron-runtime/src/simulator-ui/tab-bar-state.ts
@@ -27,6 +27,16 @@ function cloneConfig(config: TabBarConfig): TabBarConfig {
}
}
+/**
+ * Whether moving from `prev` to `next` changes the geometry the shell reports for the top page.
+ * Only the bar's presence takes layout space away from the page viewport — `wx.hideTabBar` hands that height to the page and `wx.showTabBar` takes it back, while text / icon / style / badge edits leave the layout alone.
+ *
+ * The shell asks this instead of listing the API names that move geometry, so a future action cannot be forgotten at the call site.
+ */
+export function changesReportedGeometry(prev: TabBarState, next: TabBarState): boolean {
+ return prev.visible !== next.visible
+}
+
export type TabBarAction =
| { kind: 'reset'; config: TabBarConfig | null }
| { kind: 'visibility'; visible: boolean }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 73462cc7..f23340a6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -44,8 +44,8 @@ importers:
specifier: ^7.29.7
version: 7.29.7
'@oxc-parser/binding-wasm32-wasi':
- specifier: ^0.142.0
- version: 0.142.0
+ specifier: ^0.144.0
+ version: 0.144.0
'@vue/compiler-sfc':
specifier: ^3.5.41
version: 3.5.41
@@ -62,8 +62,8 @@ importers:
specifier: ^1.2.0
version: 1.2.0
cssnano:
- specifier: ^8.0.5
- version: 8.0.5(postcss@8.5.26)
+ specifier: ^8.0.6
+ version: 8.0.6(postcss@8.5.26)
esbuild:
specifier: ^0.28.2
version: 0.28.2
@@ -77,8 +77,8 @@ importers:
specifier: ^12.0.0
version: 12.0.0
less:
- specifier: ^4.8.1
- version: 4.8.1
+ specifier: ^4.9.0
+ version: 4.9.0
magic-string:
specifier: ^0.30.21
version: 0.30.21
@@ -86,11 +86,11 @@ importers:
specifier: ^4.57.8
version: 4.57.8(tslib@2.8.1)
oxc-parser:
- specifier: ^0.142.0
- version: 0.142.0
+ specifier: ^0.144.0
+ version: 0.144.0
oxc-walker:
specifier: ^1.1.1
- version: 1.1.1(@oxc-project/types@0.142.0)(oxc-parser@0.142.0)(rolldown@1.0.3)
+ version: 1.1.1(@oxc-project/types@0.144.0)(oxc-parser@0.144.0)(rolldown@1.0.3)
path-browserify:
specifier: ^1.0.1
version: 1.0.1
@@ -158,7 +158,7 @@ importers:
version: 5.9.2
vitest:
specifier: ^4.1.4
- version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))
+ version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))
packages/devtools:
dependencies:
@@ -252,7 +252,7 @@ importers:
version: 2.0.0-alpha.41(@babel/runtime@7.29.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@vitejs/plugin-react':
specifier: ^6.0.1
- version: 6.0.1(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))
+ version: 6.0.1(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))
'@vitest/coverage-v8':
specifier: ^4.1.4
version: 4.1.4(vitest@4.1.4)
@@ -318,10 +318,10 @@ importers:
version: 5.9.2
vite:
specifier: ^8.0.8
- version: 8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)
+ version: 8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)
vitest:
specifier: ^4.1.4
- version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))
+ version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))
packages/dimina-electron-runtime:
dependencies:
@@ -364,7 +364,7 @@ importers:
version: 8.18.1
'@vitejs/plugin-react':
specifier: ^6.0.1
- version: 6.0.1(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))
+ version: 6.0.1(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))
'@vitest/coverage-v8':
specifier: ^4.1.4
version: 4.1.4(vitest@4.1.4)
@@ -388,7 +388,7 @@ importers:
version: 5.9.2
vitest:
specifier: ^4.1.4
- version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))
+ version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))
packages/electron-deck:
dependencies:
@@ -422,7 +422,7 @@ importers:
version: 18.3.7(@types/react@18.3.28)
'@vitejs/plugin-react':
specifier: ^6.0.1
- version: 6.0.1(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))
+ version: 6.0.1(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))
'@vitest/coverage-v8':
specifier: ^4.1.4
version: 4.1.4(vitest@4.1.4)
@@ -446,10 +446,10 @@ importers:
version: 5.9.2
vite:
specifier: ^8.0.16
- version: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)
+ version: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)
vitest:
specifier: ^4.1.4
- version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))
+ version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))
packages/eslint-config:
devDependencies:
@@ -506,7 +506,7 @@ importers:
version: 5.9.2
vitest:
specifier: ^4.1.4
- version: 4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))
+ version: 4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))
packages/inspect:
dependencies:
@@ -546,7 +546,7 @@ importers:
version: 5.9.2
vitest:
specifier: ^4.1.4
- version: 4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))
+ version: 4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))
packages/typescript-config: {}
@@ -566,7 +566,7 @@ importers:
version: 18.3.28
'@vitejs/plugin-react':
specifier: ^6.0.1
- version: 6.0.1(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))
+ version: 6.0.1(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))
'@vitest/coverage-v8':
specifier: ^4.1.4
version: 4.1.4(vitest@4.1.4)
@@ -590,7 +590,7 @@ importers:
version: 5.9.2
vitest:
specifier: ^4.1.4
- version: 4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))
+ version: 4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))
packages/workbench:
dependencies:
@@ -708,10 +708,10 @@ importers:
version: 5.9.2
vite:
specifier: ^8.0.8
- version: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)
+ version: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)
vitest:
specifier: ^4.1.4
- version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))
+ version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))
packages:
@@ -1009,26 +1009,26 @@ packages:
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
- '@emnapi/core@1.11.2':
- resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==}
-
'@emnapi/core@1.9.2':
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
+ '@emnapi/core@2.0.0-alpha.3':
+ resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==}
+
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
- '@emnapi/runtime@1.11.2':
- resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
-
'@emnapi/runtime@1.9.2':
resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
+ '@emnapi/runtime@2.0.0-alpha.3':
+ resolution: {integrity: sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==}
+
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
- '@emnapi/wasi-threads@1.2.2':
- resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
+ '@emnapi/wasi-threads@2.0.1':
+ resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==}
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
@@ -1731,6 +1731,13 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
+ '@napi-rs/wasm-runtime@1.2.3':
+ resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
+ peerDependencies:
+ '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
+ '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
+
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -1751,121 +1758,120 @@ packages:
resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==}
engines: {node: ^18.17.0 || >=20.5.0}
- '@oxc-parser/binding-android-arm-eabi@0.142.0':
- resolution: {integrity: sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==}
+ '@oxc-parser/binding-android-arm-eabi@0.144.0':
+ resolution: {integrity: sha512-IaoGBEp/huvja99PxI/b72TbKFzA/UzxxAka7f233dc/Tg/rRTX9Qn8IquFLWwWf4IddN/5TaJ8S4Subbjq7wQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
- '@oxc-parser/binding-android-arm64@0.142.0':
- resolution: {integrity: sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==}
+ '@oxc-parser/binding-android-arm64@0.144.0':
+ resolution: {integrity: sha512-u6fJu8XQXP99+9pYO3jq7F1D7V9fyFuDBShYFlr+gY+GcJzhveeN/zoMfuXxX6XBquJO0kjqKd7BjhJ7pClWXQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
- '@oxc-parser/binding-darwin-arm64@0.142.0':
- resolution: {integrity: sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==}
+ '@oxc-parser/binding-darwin-arm64@0.144.0':
+ resolution: {integrity: sha512-o9xGSmMQcboJLjwI+acFf6xa7nYdp0/nRFE8ry4Xrt8OviQ9ITFDBUkAXVJMOLchSV9Pu981GxJuW0mt4i6vQQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
- '@oxc-parser/binding-darwin-x64@0.142.0':
- resolution: {integrity: sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==}
+ '@oxc-parser/binding-darwin-x64@0.144.0':
+ resolution: {integrity: sha512-2yNm4tX++W3KLbyziVhs5alSb74a3C1uNDu/1P/AQj1ux8yZYuvbCAeJCCrGkr8J18ZmnBAzDthdTZBEAEb71w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
- '@oxc-parser/binding-freebsd-x64@0.142.0':
- resolution: {integrity: sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==}
+ '@oxc-parser/binding-freebsd-x64@0.144.0':
+ resolution: {integrity: sha512-TG4CjY1OjynplkF9nAQ9m9zboPJksnbAF+U/9xQGSXyIt+5sQRitwfQrUgjrG17/up9G8k/boNjLD2zp4xq1Kw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
- '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0':
- resolution: {integrity: sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==}
+ '@oxc-parser/binding-linux-arm-gnueabihf@0.144.0':
+ resolution: {integrity: sha512-i0T9NagVmqc+rbSyBr5mDKj7TCMIBRrSteQlQJt1WhWIH/sZeOP9GB09H9w98YdinuZkDIPmO7Fz0jDC7bMvSA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@oxc-parser/binding-linux-arm-musleabihf@0.142.0':
- resolution: {integrity: sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==}
+ '@oxc-parser/binding-linux-arm-musleabihf@0.144.0':
+ resolution: {integrity: sha512-YUsEqM3WMS3mOON+TFf7RzS0QthzEifx7tpUQu0GSF2MsT+D6t154ZBs6WhWaCZNl0GuVDEvndCyEAUBHzSHGw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@oxc-parser/binding-linux-arm64-gnu@0.142.0':
- resolution: {integrity: sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==}
+ '@oxc-parser/binding-linux-arm64-gnu@0.144.0':
+ resolution: {integrity: sha512-LlWH4kt+IET3qIAe0e0IFLNlQ3CVUAfN//UFsA6N0/FghMh/FBk1e+wzvgG+t8WSnXkvf8B1TovquS2EJras9g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- '@oxc-parser/binding-linux-arm64-musl@0.142.0':
- resolution: {integrity: sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==}
+ '@oxc-parser/binding-linux-arm64-musl@0.144.0':
+ resolution: {integrity: sha512-ajXbXIWBWUD4U3IQxr2p6DiXwD7GPHEBLa+JteKhIfvLmBEBdTjO28lP+5r3AF2qal8cxLERfTnGs64Z22ZuXw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- '@oxc-parser/binding-linux-ppc64-gnu@0.142.0':
- resolution: {integrity: sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==}
+ '@oxc-parser/binding-linux-ppc64-gnu@0.144.0':
+ resolution: {integrity: sha512-/+sDzL/4cWEwdqenKo/DX3gkkxu7H7ytFAtealDey/Gd59yPWn64obVk6wXKVjVfXMciUUUTySxZG9AIMX3RNQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- '@oxc-parser/binding-linux-riscv64-gnu@0.142.0':
- resolution: {integrity: sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==}
+ '@oxc-parser/binding-linux-riscv64-gnu@0.144.0':
+ resolution: {integrity: sha512-dMVhPBbrd8y6aeLd7Ihn9OZhKO8QgCQVtLBTRgbmf4lKrcR61SpaQRJPJuocTc/Cn5SJMm+alHYPnzkbOGM7Dg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- '@oxc-parser/binding-linux-riscv64-musl@0.142.0':
- resolution: {integrity: sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==}
+ '@oxc-parser/binding-linux-riscv64-musl@0.144.0':
+ resolution: {integrity: sha512-jQ8O0+b6J2IhJgm0DnqEJq8hG9OocmF1b4TBWCk08CRWqTmLZj/+lYs7w3OA60nb2SiqOmthQyJPacrCi7y+oQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- '@oxc-parser/binding-linux-s390x-gnu@0.142.0':
- resolution: {integrity: sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==}
+ '@oxc-parser/binding-linux-s390x-gnu@0.144.0':
+ resolution: {integrity: sha512-/mZxZtcGrzuvqPLPV7gjavbROYs/dHy6+yQ2Sl/2to/+qoC/v6CcruGFnfQPzQbXXTYReXJzLb5QY9KmgCbJOg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- '@oxc-parser/binding-linux-x64-gnu@0.142.0':
- resolution: {integrity: sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==}
+ '@oxc-parser/binding-linux-x64-gnu@0.144.0':
+ resolution: {integrity: sha512-/caRGFHcarHZlBrucBwQwBbzqhD+UfZZ/r7soocS0/mp6/5KTq+1Zl/OQx5lFLcN+GpUPYszbrvQU9MCFLEzJg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- '@oxc-parser/binding-linux-x64-musl@0.142.0':
- resolution: {integrity: sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==}
+ '@oxc-parser/binding-linux-x64-musl@0.144.0':
+ resolution: {integrity: sha512-qFtwAo6BWuWDjh57QDdZdYi746GW0mIeoZSGK2jJqlxIjo389Y/7lrriTOI+ou7tTvusOrSYGQZ+e+nDswt2vQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- '@oxc-parser/binding-openharmony-arm64@0.142.0':
- resolution: {integrity: sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==}
+ '@oxc-parser/binding-openharmony-arm64@0.144.0':
+ resolution: {integrity: sha512-n+NgMGWWEYpH+rlkMhDvLR2k8vJDHQp3j8SoS86IS6J0hc4kuDaiYAAvu9dF86xjeGYy+h9WLj12sylmBJV9sg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
- '@oxc-parser/binding-wasm32-wasi@0.142.0':
- resolution: {integrity: sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [wasm32]
+ '@oxc-parser/binding-wasm32-wasi@0.144.0':
+ resolution: {integrity: sha512-G+wbfbSCpdpBlJX+0+e/EKHQ852bmjySHCt8yTKzvKZlEgfIx9T+caYFXa5Ek40xsb7kdqyB4K3AD6UEVZ9FYA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
- '@oxc-parser/binding-win32-arm64-msvc@0.142.0':
- resolution: {integrity: sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==}
+ '@oxc-parser/binding-win32-arm64-msvc@0.144.0':
+ resolution: {integrity: sha512-fShxpJiCBOdG4+jBAvahTTFUDI5djXc/+IPC1ldeC8LbyCW0h9m/7oP8DRZWI7WT2Ahv8sHtZz4ugECylCFpTA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
- '@oxc-parser/binding-win32-ia32-msvc@0.142.0':
- resolution: {integrity: sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==}
+ '@oxc-parser/binding-win32-ia32-msvc@0.144.0':
+ resolution: {integrity: sha512-vFrYV+C3lJhIiSdNhdkZHnZ0YIClgTSluXaPMYjlGslVPD+uJg6K1s2xNL/X/gdBcy9IIbjbp0vNBwQhdMMdkw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
- '@oxc-parser/binding-win32-x64-msvc@0.142.0':
- resolution: {integrity: sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==}
+ '@oxc-parser/binding-win32-x64-msvc@0.144.0':
+ resolution: {integrity: sha512-0ASbKSwdeihMekyy7y4jC0CwW3XBDZk5Sw64m/W7IReVQHaduqLYssF9KCJA2oHG9oldnl/1CMxqCoImXfqQkA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -1876,8 +1882,8 @@ packages:
'@oxc-project/types@0.133.0':
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
- '@oxc-project/types@0.142.0':
- resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==}
+ '@oxc-project/types@0.144.0':
+ resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
'@parcel/watcher-android-arm64@2.5.6':
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
@@ -3114,11 +3120,6 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
- browserslist@4.28.7:
- resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==}
- engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
- hasBin: true
-
browserslist@4.28.8:
resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -3416,20 +3417,20 @@ packages:
engines: {node: '>=4'}
hasBin: true
- cssnano-preset-default@8.0.5:
- resolution: {integrity: sha512-R9O+oRNnKcVBf7GZZ7nfBcOiBZZwi3kR1HtKirBHel/gTtHLMHOCsL2H3QGy1161CPystJV4EiKniC7XyUKfcw==}
+ cssnano-preset-default@8.0.6:
+ resolution: {integrity: sha512-U5MLdiyveJNVCVR0uISgHvCkoYLLB0xeJZSY+VnBESzv1lhY04x54u9MYUNvIKmEs6hwqHVdzztajBOUf8VD4A==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- cssnano-utils@6.0.3:
- resolution: {integrity: sha512-HskzChO3gRkXBQSWg68DfwoVfptRUV2GvuiQBvt+C7mUw4VB0CvPjkC4iF4JSf7yW1/66Hsc6WJtqNqD5Ydt2Q==}
+ cssnano-utils@6.0.4:
+ resolution: {integrity: sha512-j1z2mW4MqtcGM4I8TxXAdOXUifp1DZ5/bZnyYnCpHlzQ/ilCj2voLxE0aqEKnBDA5hF6HahSY13JU8kyZ1s8Mg==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- cssnano@8.0.5:
- resolution: {integrity: sha512-Yigb8Apuqi/jWwii1XFmZwgAPZ2RHDUx/ANodBZsEQusYIFryChhwOQNKco0A7V6zgFqDDy3LqefGK6OnniGMA==}
+ cssnano@8.0.6:
+ resolution: {integrity: sha512-KDwqW0R35qIGDDWse4vRhrNtF558sUOcfuqCbB/h0bUP4+aBUpbGT5X2bQlE1gEACk+nDFAL045VyesanJFEug==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
@@ -4563,8 +4564,8 @@ packages:
lazy-val@1.0.5:
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
- less@4.8.1:
- resolution: {integrity: sha512-jQ3lRIo1aUtiWVYXZ7mk4+V4BjCGswF3IxTLJ+4RUta8ZiHh8lhkig2G8dya2eCcyR1dYUvzuV46EkJN8PSwww==}
+ less@4.9.0:
+ resolution: {integrity: sha512-umRhrCH7fCi8Uj2RcwKjJdvUORTjeWqkdKx0LbcZvjIwsAVsnIAGcxHaqowPeBFBjQuWOeC/bve0AlpFzF/+SQ==}
engines: {node: '>=18'}
hasBin: true
@@ -5015,8 +5016,8 @@ packages:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
- oxc-parser@0.142.0:
- resolution: {integrity: sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==}
+ oxc-parser@0.144.0:
+ resolution: {integrity: sha512-eacM4wMgGWXctHubY262yo+50E76qtQBqe+uK73YEV1IT3qP12Acbnf9Nc8t+agIAdnko9iVT4KF83/d0EjY5w==}
engines: {node: ^20.19.0 || >=22.12.0}
oxc-walker@1.1.1:
@@ -5180,38 +5181,38 @@ packages:
peerDependencies:
postcss: ^8.4.38
- postcss-colormin@8.0.3:
- resolution: {integrity: sha512-kypgzYcOcrKsrAZyId3TMumHtIiwZxgK1h5B33S4RjQNV02RHKrXCWP8ndyx5S0R5mk4pkcIfJ95o3BwIN8r2Q==}
+ postcss-colormin@8.0.4:
+ resolution: {integrity: sha512-iQ8Eh6Fb3FJx32zduprL33D80bPnE9F4nm+EqvvoHvoK72H7XjvH4GzbD7VlIowmtzf96tgtkjZgmQ/bAi/CYA==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-convert-values@8.0.3:
- resolution: {integrity: sha512-14lU1u5MeX8oGQ4zMhiMYhFcav9ebgmjjxTYwk77pmZ5A9Rq8hr5X/XZaTfffvbIGMOoLK82YDsLxA+1hFGbbA==}
+ postcss-convert-values@8.0.4:
+ resolution: {integrity: sha512-ifBmAJBfpDymi7r/CqxYRJWlG+BLdVmusUC/kFPOqH/+TitdBeHA68CYqY79wXL+iQnqg6e4LGHA2Mte14X6Ig==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-discard-comments@8.0.3:
- resolution: {integrity: sha512-oDe4ITEfp1/113ebPi/ujyfWX2E9+vbHhY0dPnypgHwIgw8LBQ7MczmtAbccw8gUs+Zlmgt80qtTKS0t8eCi+Q==}
+ postcss-discard-comments@8.0.4:
+ resolution: {integrity: sha512-SU1uLYRQPKLJJmqEmqzu+Ze+9xDurBm7m/LOzXG/ANBAfAGLjbQF+oIyZnf3jV3F155q5rRMYXsoTNEJsPyHng==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-discard-duplicates@8.0.3:
- resolution: {integrity: sha512-6f6ZVBozciZ0nG7nurrHk+K2yNeBgEoOjZvj5JwFThK982tSPyOjtClbkTYgqwDuSFjBvtu5C9Iz/2QEPvUg+Q==}
+ postcss-discard-duplicates@8.0.4:
+ resolution: {integrity: sha512-/ugMYYTE+IpHpTmwz6/S8w+blCz9pZNroUgadTdlbPnHoLLWis9QvwmoyWui2mOgFzuRM6Me7AnoGU3i0Ua2ng==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-discard-empty@8.0.3:
- resolution: {integrity: sha512-IpqNmuH9djHODVELb5c4tC6274pxjEWqrpGtTu7BR8TtOz3beqTv7JjE9xSuGOacphh9XZbbyEebvfMxYC5PTA==}
+ postcss-discard-empty@8.0.4:
+ resolution: {integrity: sha512-utsxD6q3E9FCwxBRTe5/Jh9ticSOUmfovDfX6zrLuuX8ZfycSkrsqog2ZwGtVVrAS9SMxAhD73QHe9U/gopVJA==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-discard-overridden@8.0.3:
- resolution: {integrity: sha512-G2Ksn7kkNsNlsYsgfVn0YFfcGewJTuc1KOENvoVtwu2bSEyR28+GaomozkV4DPUBbKF8Nvco/9rLyKkgf/TY6w==}
+ postcss-discard-overridden@8.0.4:
+ resolution: {integrity: sha512-NqupxmnSSfWPJJDYbQk1qXWRwr+lw4Kyp/LSOjqwTpy/vSwo34EVXYKywgemSWpgh39P7Cs/G+tqynbRlXoJhw==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
@@ -5246,38 +5247,38 @@ packages:
yaml:
optional: true
- postcss-merge-longhand@8.0.3:
- resolution: {integrity: sha512-/Byag1rLsEffmnidL+8HGwr0AsQWiaY2gpChEKwKP4+YcBtzz44rKVxAJLdhQtRLFrpGiBoLzCdCfH/ZTkozuA==}
+ postcss-merge-longhand@8.0.4:
+ resolution: {integrity: sha512-ammolHhMvuTz/L9YN/ge1SWbUDcD3Y9o2gXlo3S6H4MCJHheyVPwcI/LAnSuNT4gcUmSF9pNTXo7+3M/2MFPAQ==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-merge-rules@8.0.3:
- resolution: {integrity: sha512-gBnrjp2ebQyrBNDqxORirC6ZM6g4cHKglAwGf1JiuKmDPjUJbNM6WhxYMYkHf+hxysnZfq27+TtxGrYESv3GMQ==}
+ postcss-merge-rules@8.0.4:
+ resolution: {integrity: sha512-bppiIHxg0zUCwdt9MVYy6nM2dBgPBJdZPD5Y3Eg61p79JVaNQXTzghQKVcaGX2vi5v2rz9+zydIIFLTxSh1akw==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-minify-font-values@8.0.3:
- resolution: {integrity: sha512-kAXxTCIVub5LZvyKTr9AObrRxri0WtWpNVsG6R9NdBKHDHYu5WNAnZRntgOforsKeFeZRZvOOXnTqOANQeyzKg==}
+ postcss-minify-font-values@8.0.4:
+ resolution: {integrity: sha512-wZZgJ87U5WbQCEM0EOY/oM7M1a+sqW6vrOClGC9lNbsY0HYmbMFvFuukBn8PzF+ikKmaT0QB+uSAHTRLzMkD+A==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-minify-gradients@8.0.3:
- resolution: {integrity: sha512-0O9UDPjIB4OikREx/aOwPY3pco23txEpXpdTlzfoSrVQllNh5j9GaCkThA5ylsKcDWz5sunV3tFW9+zlHFWUww==}
+ postcss-minify-gradients@8.0.4:
+ resolution: {integrity: sha512-78cIzfNlG4uMT1wgh6svmAw/sFwQPZ9JRqq4zxJpcdOMvXQz3Hh1ZBdX5GeBONp0AoqNVQiHZ2VnQeF+HldT/Q==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-minify-params@8.0.3:
- resolution: {integrity: sha512-DuSZEJbWxUX7wvIkz6K4qN8zs5unI/MSg7gcH4AXXA3524OcI4BOBUhR8B8OIBIi9FfcBbfgHYlATVhMeDBXNg==}
+ postcss-minify-params@8.0.4:
+ resolution: {integrity: sha512-TDx9O/ni7KazRK32pVvoo3MjExyVxWE0949vB2XZ0oHnxdAtQFHa9oG21wWkvsOL3osjiOVejwDLe6a0d0EuSw==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-minify-selectors@8.0.4:
- resolution: {integrity: sha512-+rqBW9gYNLq2RNBwfVx2QdElj2cTiqbNwah+bw1XvyhCnRe4uFLNW2kny0VspFyYR7OGuVyWZaz2K4DbX5J9sw==}
+ postcss-minify-selectors@8.0.5:
+ resolution: {integrity: sha512-i+PlhVCaPa7xevjhpActH2PrP27kDNSuuFf/ZGk8YrIcd1pd3Mfcfiv8H7dbY1/vz7U+QparioAtJaFV+IEwQg==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
@@ -5288,74 +5289,74 @@ packages:
peerDependencies:
postcss: ^8.2.14
- postcss-normalize-charset@8.0.3:
- resolution: {integrity: sha512-losd0Uu4XVpiKN5tcGh32QxpB9t3S09PMQaW6I2GszKl9wOR0I34DY0a8ApO50jzfBC52lv8jPpkvh5ltbgajg==}
+ postcss-normalize-charset@8.0.4:
+ resolution: {integrity: sha512-ihNV/Q9V/+Q9NTqgoK4FOwI7ipqJlsaxoTWxkGyCMi4ArLKX8HqLYIqATB/8xhXd9K88PCXqdR8218u9uuyc4w==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-normalize-display-values@8.0.3:
- resolution: {integrity: sha512-IaCp/Rp7bg0e8Hv0Wc4G+niuuA61iGOSzhGBUeo6P6qyoumyFbOzYbYWZSkzMNsC4o2gfHAj3q2VbQfds8Huzw==}
+ postcss-normalize-display-values@8.0.4:
+ resolution: {integrity: sha512-F8DGtzelJDV6S5mhcugc+EJ8sXI+kFv2PBedkfMWqd8mvTE4qyy3kva6AQwjy2+L2lZ9TGTkSumdq+n3EhGeAw==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-normalize-positions@8.0.3:
- resolution: {integrity: sha512-OA/n9pI6W66Sv1vki0MLv/0QpDECV8i3UHwlXPVnv3XewsBjx310NQDV+ybMx2ZZQc/RHS7Uf8ES+oxLVhd0iA==}
+ postcss-normalize-positions@8.0.4:
+ resolution: {integrity: sha512-anOMhqe6Z0OP4jvchK1v1hIG08kO7rsp8uHrxT2+XaKp8wbvVcUO3QHUzRmQDazWUPtRzy1h2UlixyJenF42sg==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-normalize-repeat-style@8.0.3:
- resolution: {integrity: sha512-lMCbxiLBd7awGEoRM1WD02R08XpEGDHg3x7z9VSWnZibgSGHNJ+k7aheucuLBcxPAHWPudwc8O65sYD2FDx0Hg==}
+ postcss-normalize-repeat-style@8.0.4:
+ resolution: {integrity: sha512-vY8+k+/bFT7B8gzlHVye8FxBTMEpUAQxAgb4UYFAGGsKmXfwEvc7VPs+kpFNrTeqVKAvVh8+VVhZ+f3S4TWJPg==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-normalize-string@8.0.3:
- resolution: {integrity: sha512-F8jkemEEIGDjerUzTa5w18CQc4GfhpJdEk78LdtwOjLjG1DlaDZyCdec+PyxCHjSY7vsbULXhlk24vSA+eUtIg==}
+ postcss-normalize-string@8.0.4:
+ resolution: {integrity: sha512-dWnV1frIV1XUlSYzJudceAbQ7PGyho9EgVHQXMcAoog6Nwnet3G9rz8IXcCb0EF2eefmpYG9hDT9+yWJOxc5zw==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-normalize-timing-functions@8.0.3:
- resolution: {integrity: sha512-6MO4j7ySljCmhPKx6GLkIpDdV6nOhb5UaB8j/0i0EbfooH1NeVQG60Zt7qAQ9h21HUwV6kQDIbWGqU78DhPHtw==}
+ postcss-normalize-timing-functions@8.0.4:
+ resolution: {integrity: sha512-VANJsokAWdQ03ZJwmcdEKXJjVHRtMgalB/BgbCgN3CRTdbwB5TXlSDRmdShSCxpnZs8WLAm0mPa//p5o5D/ZTg==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-normalize-unicode@8.0.3:
- resolution: {integrity: sha512-gfMC9A0z8d1HrS792ZFIUMZeTysN/GrtQEiyV/aSJPBwqOOak9BTGTSUp1VHozNuEeQs4MUoO+fBQzDXpRX28A==}
+ postcss-normalize-unicode@8.0.4:
+ resolution: {integrity: sha512-SxVEoFdYed0bxxwZwAXnaAZ2lzNb5jtiQvYWyMIEqnGOzCuztWpVO8LvsqpOfUIacOb6oYaWi7WOAZR36OhYqw==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-normalize-url@8.0.3:
- resolution: {integrity: sha512-ksA0HgWATlnIzDaB9gFPslXxJkTa7aHZK3jWSbbyJdFJuRIY0BOL0eNMRrZNpe6//g4Af/iB00ETbT0cNmCzKQ==}
+ postcss-normalize-url@8.0.4:
+ resolution: {integrity: sha512-JKZW8KRHkLEYkUb/55n3knDUMZeyvxlTv7ZA82bcHiCjL81b0Yyf9fIUBAYKZ6ucDgKfCDM78xhXuteVlk/o9Q==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-normalize-whitespace@8.0.3:
- resolution: {integrity: sha512-Hz/IeeZXk1686EZtnCKKIkU8Mu+PGTxPhkuXnKxJZCD8lkVgD9blIhymu3I/itL4FoOyFTWzh7hQvIri8SbrXg==}
+ postcss-normalize-whitespace@8.0.4:
+ resolution: {integrity: sha512-LdEzfN2xKS52Hn3iGK4szK8E9d/lCkcrEMsxi4wY5ibXyKDyNmQW+YMlPX8C0uYhdfeFHQLktPOkXJGUZjJDUw==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-ordered-values@8.0.3:
- resolution: {integrity: sha512-Sp62UbMrsCNcPvtnme1Kz+nNJK3tqCqRvGiKdL7ugYgu3/m8buMlFy3QO/BJ0dJQHHJP2u6CESrw4xhKFJTvvA==}
+ postcss-ordered-values@8.0.4:
+ resolution: {integrity: sha512-hJ8elrQlYgAynaap0275AhUWTq8+aeWRjkKfRTT/id2AAttjbUWvPQUgzEnANU3KHHDdna86KyNrdk4wslGZNQ==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-reduce-initial@8.0.3:
- resolution: {integrity: sha512-z2cMLQtjr+gXd4QIg2O6c21xGsk1QV2HNKaiYVvxZYRrvyuAqPAHFfRSo+7zbGc/n7rilzEFkljJN5WihG99AQ==}
+ postcss-reduce-initial@8.0.4:
+ resolution: {integrity: sha512-6SK1CZN9tmVHXhFA+Up7aNjJzIYKgo9I4yHluQKoO8tIa5iGc88x8BiJmMDUvrDtYgt3IZIruS3AGCAdQMAY9Q==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-reduce-transforms@8.0.3:
- resolution: {integrity: sha512-MKebqhCviCaOlz9ZnlQxviw0R94q3POvxv3m2Xk1IEyBrk2lsNiKBJsuwXLWHWkQB3y+pQDE0FA26J4+NYhzLg==}
+ postcss-reduce-transforms@8.0.4:
+ resolution: {integrity: sha512-ZGU7/R0GmjE3x2mOGB0g9Ohx74X+JjYX62RB5IBYLOe6R0fuHGZ76HtTY2CdM5NE8Yey/ew5clEf8iZQnjDjWA==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
@@ -5368,14 +5369,14 @@ packages:
resolution: {integrity: sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==}
engines: {node: '>=4'}
- postcss-svgo@8.0.4:
- resolution: {integrity: sha512-cmxRK3zz5BLFO/r/crOHy4QosyNCHpyAG9fOjtOSAEKkajcAK5xyURRWcotPOXcz88VbzIrYJCWVAGjrX4GeJw==}
+ postcss-svgo@8.0.5:
+ resolution: {integrity: sha512-8B5r9VfLVD2lANKxDi4FXeP2MX6NdaGIQwaMVXXy5DhzAPO3CdHqPYqknF63y7tuQLB+rSvYyNltW/tHHg4fAg==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
- postcss-unique-selectors@8.0.3:
- resolution: {integrity: sha512-uKwlnCNyKmny7yFPOnQJAN82Er0WzGQbSlpZHTKTqlTQsvZF+skA4ANMHqKh+68jQYet6IotH3dtxs1VdyBdxw==}
+ postcss-unique-selectors@8.0.4:
+ resolution: {integrity: sha512-Sby0EtmD1mlvcZSWP5eXjxhxVgvXE5QVRzd+8NH0IbF+lYQIM57ybwAvJYYtI/fexMgCWcRnDETovjKOcLJb8w==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
@@ -5877,8 +5878,8 @@ packages:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'}
- stylehacks@8.0.3:
- resolution: {integrity: sha512-cHciVnyMEuLOYt6AGCTyzRjEvBTvA+0H/QThOFgKTI9uuFK4RA34gLVEt/Uuz11rtSPvZHjMWLjTazF9pLMPog==}
+ stylehacks@8.0.4:
+ resolution: {integrity: sha512-irgZeyYBFVkb8k7yTRQ9No/bTniTGqzptGVMG1/Mj3N2YnI8nIozzY1pcok4i01NfrCSqvc0PdQB6O6kr6dJHw==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
peerDependencies:
postcss: ^8.5.26
@@ -6565,7 +6566,7 @@ snapshots:
dependencies:
'@babel/compat-data': 7.29.0
'@babel/helper-validator-option': 7.27.1
- browserslist: 4.28.7
+ browserslist: 4.28.8
lru-cache: 5.1.1
semver: 6.3.1
@@ -6948,37 +6949,37 @@ snapshots:
tslib: 2.8.1
optional: true
- '@emnapi/core@1.11.2':
- dependencies:
- '@emnapi/wasi-threads': 1.2.2
- tslib: 2.8.1
-
'@emnapi/core@1.9.2':
dependencies:
'@emnapi/wasi-threads': 1.2.1
tslib: 2.8.1
optional: true
- '@emnapi/runtime@1.10.0':
+ '@emnapi/core@2.0.0-alpha.3':
dependencies:
+ '@emnapi/wasi-threads': 2.0.1
tslib: 2.8.1
- optional: true
- '@emnapi/runtime@1.11.2':
+ '@emnapi/runtime@1.10.0':
dependencies:
tslib: 2.8.1
+ optional: true
'@emnapi/runtime@1.9.2':
dependencies:
tslib: 2.8.1
optional: true
+ '@emnapi/runtime@2.0.0-alpha.3':
+ dependencies:
+ tslib: 2.8.1
+
'@emnapi/wasi-threads@1.2.1':
dependencies:
tslib: 2.8.1
optional: true
- '@emnapi/wasi-threads@1.2.2':
+ '@emnapi/wasi-threads@2.0.1':
dependencies:
tslib: 2.8.1
@@ -7630,12 +7631,6 @@ snapshots:
'@tybys/wasm-util': 0.10.3
optional: true
- '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)':
- dependencies:
- '@emnapi/core': 1.11.2
- '@emnapi/runtime': 1.11.2
- '@tybys/wasm-util': 0.10.3
-
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
dependencies:
'@emnapi/core': 1.9.2
@@ -7643,6 +7638,12 @@ snapshots:
'@tybys/wasm-util': 0.10.3
optional: true
+ '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)':
+ dependencies:
+ '@emnapi/core': 2.0.0-alpha.3
+ '@emnapi/runtime': 2.0.0-alpha.3
+ '@tybys/wasm-util': 0.10.3
+
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -7669,74 +7670,74 @@ snapshots:
dependencies:
semver: 7.8.5
- '@oxc-parser/binding-android-arm-eabi@0.142.0':
+ '@oxc-parser/binding-android-arm-eabi@0.144.0':
optional: true
- '@oxc-parser/binding-android-arm64@0.142.0':
+ '@oxc-parser/binding-android-arm64@0.144.0':
optional: true
- '@oxc-parser/binding-darwin-arm64@0.142.0':
+ '@oxc-parser/binding-darwin-arm64@0.144.0':
optional: true
- '@oxc-parser/binding-darwin-x64@0.142.0':
+ '@oxc-parser/binding-darwin-x64@0.144.0':
optional: true
- '@oxc-parser/binding-freebsd-x64@0.142.0':
+ '@oxc-parser/binding-freebsd-x64@0.144.0':
optional: true
- '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0':
+ '@oxc-parser/binding-linux-arm-gnueabihf@0.144.0':
optional: true
- '@oxc-parser/binding-linux-arm-musleabihf@0.142.0':
+ '@oxc-parser/binding-linux-arm-musleabihf@0.144.0':
optional: true
- '@oxc-parser/binding-linux-arm64-gnu@0.142.0':
+ '@oxc-parser/binding-linux-arm64-gnu@0.144.0':
optional: true
- '@oxc-parser/binding-linux-arm64-musl@0.142.0':
+ '@oxc-parser/binding-linux-arm64-musl@0.144.0':
optional: true
- '@oxc-parser/binding-linux-ppc64-gnu@0.142.0':
+ '@oxc-parser/binding-linux-ppc64-gnu@0.144.0':
optional: true
- '@oxc-parser/binding-linux-riscv64-gnu@0.142.0':
+ '@oxc-parser/binding-linux-riscv64-gnu@0.144.0':
optional: true
- '@oxc-parser/binding-linux-riscv64-musl@0.142.0':
+ '@oxc-parser/binding-linux-riscv64-musl@0.144.0':
optional: true
- '@oxc-parser/binding-linux-s390x-gnu@0.142.0':
+ '@oxc-parser/binding-linux-s390x-gnu@0.144.0':
optional: true
- '@oxc-parser/binding-linux-x64-gnu@0.142.0':
+ '@oxc-parser/binding-linux-x64-gnu@0.144.0':
optional: true
- '@oxc-parser/binding-linux-x64-musl@0.142.0':
+ '@oxc-parser/binding-linux-x64-musl@0.144.0':
optional: true
- '@oxc-parser/binding-openharmony-arm64@0.142.0':
+ '@oxc-parser/binding-openharmony-arm64@0.144.0':
optional: true
- '@oxc-parser/binding-wasm32-wasi@0.142.0':
+ '@oxc-parser/binding-wasm32-wasi@0.144.0':
dependencies:
- '@emnapi/core': 1.11.2
- '@emnapi/runtime': 1.11.2
- '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)
+ '@emnapi/core': 2.0.0-alpha.3
+ '@emnapi/runtime': 2.0.0-alpha.3
+ '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)
- '@oxc-parser/binding-win32-arm64-msvc@0.142.0':
+ '@oxc-parser/binding-win32-arm64-msvc@0.144.0':
optional: true
- '@oxc-parser/binding-win32-ia32-msvc@0.142.0':
+ '@oxc-parser/binding-win32-ia32-msvc@0.144.0':
optional: true
- '@oxc-parser/binding-win32-x64-msvc@0.142.0':
+ '@oxc-parser/binding-win32-x64-msvc@0.144.0':
optional: true
'@oxc-project/types@0.124.0': {}
'@oxc-project/types@0.133.0': {}
- '@oxc-project/types@0.142.0': {}
+ '@oxc-project/types@0.144.0': {}
'@parcel/watcher-android-arm64@2.5.6':
optional: true
@@ -8543,20 +8544,20 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
- '@vitejs/plugin-react@6.0.1(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))':
+ '@vitejs/plugin-react@6.0.1(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7
- vite: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)
+ vite: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)
- '@vitejs/plugin-react@6.0.1(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))':
+ '@vitejs/plugin-react@6.0.1(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7
- vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)
+ vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)
- '@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))':
+ '@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7
- vite: 8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)
+ vite: 8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)
'@vitest/coverage-v8@4.1.4(vitest@4.1.4)':
dependencies:
@@ -8570,7 +8571,7 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
- vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))
+ vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))
'@vitest/expect@4.1.4':
dependencies:
@@ -8581,37 +8582,37 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
- '@vitest/mocker@4.1.4(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))':
+ '@vitest/mocker@4.1.4(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)
+ vite: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)
- '@vitest/mocker@4.1.4(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))':
+ '@vitest/mocker@4.1.4(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)
+ vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)
- '@vitest/mocker@4.1.4(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))':
+ '@vitest/mocker@4.1.4(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)
+ vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)
- '@vitest/mocker@4.1.4(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))':
+ '@vitest/mocker@4.1.4(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)
+ vite: 8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)
'@vitest/pretty-format@4.1.4':
dependencies:
@@ -8982,19 +8983,11 @@ snapshots:
browserslist@4.28.6:
dependencies:
baseline-browser-mapping: 2.10.43
- caniuse-lite: 1.0.30001806
+ caniuse-lite: 1.0.30001809
electron-to-chromium: 1.5.393
node-releases: 2.0.51
update-browserslist-db: 1.2.3(browserslist@4.28.6)
- browserslist@4.28.7:
- dependencies:
- baseline-browser-mapping: 2.11.12
- caniuse-lite: 1.0.30001806
- electron-to-chromium: 1.5.393
- node-releases: 2.0.51
- update-browserslist-db: 1.2.3(browserslist@4.28.7)
-
browserslist@4.28.8:
dependencies:
baseline-browser-mapping: 2.11.12
@@ -9102,7 +9095,7 @@ snapshots:
caniuse-api@4.0.0:
dependencies:
browserslist: 4.28.8
- caniuse-lite: 1.0.30001806
+ caniuse-lite: 1.0.30001809
caniuse-lite@1.0.30001788: {}
@@ -9334,46 +9327,46 @@ snapshots:
cssesc@3.0.0: {}
- cssnano-preset-default@8.0.5(postcss@8.5.26):
+ cssnano-preset-default@8.0.6(postcss@8.5.26):
dependencies:
browserslist: 4.28.8
- cssnano-utils: 6.0.3(postcss@8.5.26)
+ cssnano-utils: 6.0.4(postcss@8.5.26)
postcss: 8.5.26
postcss-calc: 10.1.1(postcss@8.5.26)
- postcss-colormin: 8.0.3(postcss@8.5.26)
- postcss-convert-values: 8.0.3(postcss@8.5.26)
- postcss-discard-comments: 8.0.3(postcss@8.5.26)
- postcss-discard-duplicates: 8.0.3(postcss@8.5.26)
- postcss-discard-empty: 8.0.3(postcss@8.5.26)
- postcss-discard-overridden: 8.0.3(postcss@8.5.26)
- postcss-merge-longhand: 8.0.3(postcss@8.5.26)
- postcss-merge-rules: 8.0.3(postcss@8.5.26)
- postcss-minify-font-values: 8.0.3(postcss@8.5.26)
- postcss-minify-gradients: 8.0.3(postcss@8.5.26)
- postcss-minify-params: 8.0.3(postcss@8.5.26)
- postcss-minify-selectors: 8.0.4(postcss@8.5.26)
- postcss-normalize-charset: 8.0.3(postcss@8.5.26)
- postcss-normalize-display-values: 8.0.3(postcss@8.5.26)
- postcss-normalize-positions: 8.0.3(postcss@8.5.26)
- postcss-normalize-repeat-style: 8.0.3(postcss@8.5.26)
- postcss-normalize-string: 8.0.3(postcss@8.5.26)
- postcss-normalize-timing-functions: 8.0.3(postcss@8.5.26)
- postcss-normalize-unicode: 8.0.3(postcss@8.5.26)
- postcss-normalize-url: 8.0.3(postcss@8.5.26)
- postcss-normalize-whitespace: 8.0.3(postcss@8.5.26)
- postcss-ordered-values: 8.0.3(postcss@8.5.26)
- postcss-reduce-initial: 8.0.3(postcss@8.5.26)
- postcss-reduce-transforms: 8.0.3(postcss@8.5.26)
- postcss-svgo: 8.0.4(postcss@8.5.26)
- postcss-unique-selectors: 8.0.3(postcss@8.5.26)
-
- cssnano-utils@6.0.3(postcss@8.5.26):
+ postcss-colormin: 8.0.4(postcss@8.5.26)
+ postcss-convert-values: 8.0.4(postcss@8.5.26)
+ postcss-discard-comments: 8.0.4(postcss@8.5.26)
+ postcss-discard-duplicates: 8.0.4(postcss@8.5.26)
+ postcss-discard-empty: 8.0.4(postcss@8.5.26)
+ postcss-discard-overridden: 8.0.4(postcss@8.5.26)
+ postcss-merge-longhand: 8.0.4(postcss@8.5.26)
+ postcss-merge-rules: 8.0.4(postcss@8.5.26)
+ postcss-minify-font-values: 8.0.4(postcss@8.5.26)
+ postcss-minify-gradients: 8.0.4(postcss@8.5.26)
+ postcss-minify-params: 8.0.4(postcss@8.5.26)
+ postcss-minify-selectors: 8.0.5(postcss@8.5.26)
+ postcss-normalize-charset: 8.0.4(postcss@8.5.26)
+ postcss-normalize-display-values: 8.0.4(postcss@8.5.26)
+ postcss-normalize-positions: 8.0.4(postcss@8.5.26)
+ postcss-normalize-repeat-style: 8.0.4(postcss@8.5.26)
+ postcss-normalize-string: 8.0.4(postcss@8.5.26)
+ postcss-normalize-timing-functions: 8.0.4(postcss@8.5.26)
+ postcss-normalize-unicode: 8.0.4(postcss@8.5.26)
+ postcss-normalize-url: 8.0.4(postcss@8.5.26)
+ postcss-normalize-whitespace: 8.0.4(postcss@8.5.26)
+ postcss-ordered-values: 8.0.4(postcss@8.5.26)
+ postcss-reduce-initial: 8.0.4(postcss@8.5.26)
+ postcss-reduce-transforms: 8.0.4(postcss@8.5.26)
+ postcss-svgo: 8.0.5(postcss@8.5.26)
+ postcss-unique-selectors: 8.0.4(postcss@8.5.26)
+
+ cssnano-utils@6.0.4(postcss@8.5.26):
dependencies:
postcss: 8.5.26
- cssnano@8.0.5(postcss@8.5.26):
+ cssnano@8.0.6(postcss@8.5.26):
dependencies:
- cssnano-preset-default: 8.0.5(postcss@8.5.26)
+ cssnano-preset-default: 8.0.6(postcss@8.5.26)
lilconfig: 3.1.3
postcss: 8.5.26
@@ -10786,7 +10779,7 @@ snapshots:
lazy-val@1.0.5: {}
- less@4.8.1:
+ less@4.9.0:
dependencies:
copy-anything: 3.0.5
parse-node-version: 1.0.1
@@ -11267,35 +11260,34 @@ snapshots:
object-keys: 1.1.1
safe-push-apply: 1.0.0
- oxc-parser@0.142.0:
+ oxc-parser@0.144.0:
dependencies:
- '@oxc-project/types': 0.142.0
+ '@oxc-project/types': 0.144.0
optionalDependencies:
- '@oxc-parser/binding-android-arm-eabi': 0.142.0
- '@oxc-parser/binding-android-arm64': 0.142.0
- '@oxc-parser/binding-darwin-arm64': 0.142.0
- '@oxc-parser/binding-darwin-x64': 0.142.0
- '@oxc-parser/binding-freebsd-x64': 0.142.0
- '@oxc-parser/binding-linux-arm-gnueabihf': 0.142.0
- '@oxc-parser/binding-linux-arm-musleabihf': 0.142.0
- '@oxc-parser/binding-linux-arm64-gnu': 0.142.0
- '@oxc-parser/binding-linux-arm64-musl': 0.142.0
- '@oxc-parser/binding-linux-ppc64-gnu': 0.142.0
- '@oxc-parser/binding-linux-riscv64-gnu': 0.142.0
- '@oxc-parser/binding-linux-riscv64-musl': 0.142.0
- '@oxc-parser/binding-linux-s390x-gnu': 0.142.0
- '@oxc-parser/binding-linux-x64-gnu': 0.142.0
- '@oxc-parser/binding-linux-x64-musl': 0.142.0
- '@oxc-parser/binding-openharmony-arm64': 0.142.0
- '@oxc-parser/binding-wasm32-wasi': 0.142.0
- '@oxc-parser/binding-win32-arm64-msvc': 0.142.0
- '@oxc-parser/binding-win32-ia32-msvc': 0.142.0
- '@oxc-parser/binding-win32-x64-msvc': 0.142.0
-
- oxc-walker@1.1.1(@oxc-project/types@0.142.0)(oxc-parser@0.142.0)(rolldown@1.0.3):
+ '@oxc-parser/binding-android-arm-eabi': 0.144.0
+ '@oxc-parser/binding-android-arm64': 0.144.0
+ '@oxc-parser/binding-darwin-arm64': 0.144.0
+ '@oxc-parser/binding-darwin-x64': 0.144.0
+ '@oxc-parser/binding-freebsd-x64': 0.144.0
+ '@oxc-parser/binding-linux-arm-gnueabihf': 0.144.0
+ '@oxc-parser/binding-linux-arm-musleabihf': 0.144.0
+ '@oxc-parser/binding-linux-arm64-gnu': 0.144.0
+ '@oxc-parser/binding-linux-arm64-musl': 0.144.0
+ '@oxc-parser/binding-linux-ppc64-gnu': 0.144.0
+ '@oxc-parser/binding-linux-riscv64-gnu': 0.144.0
+ '@oxc-parser/binding-linux-riscv64-musl': 0.144.0
+ '@oxc-parser/binding-linux-s390x-gnu': 0.144.0
+ '@oxc-parser/binding-linux-x64-gnu': 0.144.0
+ '@oxc-parser/binding-linux-x64-musl': 0.144.0
+ '@oxc-parser/binding-openharmony-arm64': 0.144.0
+ '@oxc-parser/binding-win32-arm64-msvc': 0.144.0
+ '@oxc-parser/binding-win32-ia32-msvc': 0.144.0
+ '@oxc-parser/binding-win32-x64-msvc': 0.144.0
+
+ oxc-walker@1.1.1(@oxc-project/types@0.144.0)(oxc-parser@0.144.0)(rolldown@1.0.3):
optionalDependencies:
- '@oxc-project/types': 0.142.0
- oxc-parser: 0.142.0
+ '@oxc-project/types': 0.144.0
+ oxc-parser: 0.144.0
rolldown: 1.0.3
p-cancelable@2.1.1: {}
@@ -11411,7 +11403,7 @@ snapshots:
postcss-selector-parser: 7.1.5
postcss-value-parser: 4.2.0
- postcss-colormin@8.0.3(postcss@8.5.26):
+ postcss-colormin@8.0.4(postcss@8.5.26):
dependencies:
'@colordx/core': 5.5.0
browserslist: 4.28.8
@@ -11419,26 +11411,26 @@ snapshots:
postcss: 8.5.26
postcss-value-parser: 4.2.0
- postcss-convert-values@8.0.3(postcss@8.5.26):
+ postcss-convert-values@8.0.4(postcss@8.5.26):
dependencies:
browserslist: 4.28.8
postcss: 8.5.26
postcss-value-parser: 4.2.0
- postcss-discard-comments@8.0.3(postcss@8.5.26):
+ postcss-discard-comments@8.0.4(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.5
- postcss-discard-duplicates@8.0.3(postcss@8.5.26):
+ postcss-discard-duplicates@8.0.4(postcss@8.5.26):
dependencies:
postcss: 8.5.26
- postcss-discard-empty@8.0.3(postcss@8.5.26):
+ postcss-discard-empty@8.0.4(postcss@8.5.26):
dependencies:
postcss: 8.5.26
- postcss-discard-overridden@8.0.3(postcss@8.5.26):
+ postcss-discard-overridden@8.0.4(postcss@8.5.26):
dependencies:
postcss: 8.5.26
@@ -11462,40 +11454,40 @@ snapshots:
postcss: 8.5.26
yaml: 2.9.0
- postcss-merge-longhand@8.0.3(postcss@8.5.26):
+ postcss-merge-longhand@8.0.4(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-value-parser: 4.2.0
- stylehacks: 8.0.3(postcss@8.5.26)
+ stylehacks: 8.0.4(postcss@8.5.26)
- postcss-merge-rules@8.0.3(postcss@8.5.26):
+ postcss-merge-rules@8.0.4(postcss@8.5.26):
dependencies:
browserslist: 4.28.8
caniuse-api: 4.0.0
- cssnano-utils: 6.0.3(postcss@8.5.26)
+ cssnano-utils: 6.0.4(postcss@8.5.26)
postcss: 8.5.26
postcss-selector-parser: 7.1.5
- postcss-minify-font-values@8.0.3(postcss@8.5.26):
+ postcss-minify-font-values@8.0.4(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-value-parser: 4.2.0
- postcss-minify-gradients@8.0.3(postcss@8.5.26):
+ postcss-minify-gradients@8.0.4(postcss@8.5.26):
dependencies:
'@colordx/core': 5.5.0
- cssnano-utils: 6.0.3(postcss@8.5.26)
+ cssnano-utils: 6.0.4(postcss@8.5.26)
postcss: 8.5.26
postcss-value-parser: 4.2.0
- postcss-minify-params@8.0.3(postcss@8.5.26):
+ postcss-minify-params@8.0.4(postcss@8.5.26):
dependencies:
browserslist: 4.28.8
- cssnano-utils: 6.0.3(postcss@8.5.26)
+ cssnano-utils: 6.0.4(postcss@8.5.26)
postcss: 8.5.26
postcss-value-parser: 4.2.0
- postcss-minify-selectors@8.0.4(postcss@8.5.26):
+ postcss-minify-selectors@8.0.5(postcss@8.5.26):
dependencies:
browserslist: 4.28.8
caniuse-api: 4.0.0
@@ -11508,64 +11500,64 @@ snapshots:
postcss: 8.5.26
postcss-selector-parser: 6.1.4
- postcss-normalize-charset@8.0.3(postcss@8.5.26):
+ postcss-normalize-charset@8.0.4(postcss@8.5.26):
dependencies:
postcss: 8.5.26
- postcss-normalize-display-values@8.0.3(postcss@8.5.26):
+ postcss-normalize-display-values@8.0.4(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-value-parser: 4.2.0
- postcss-normalize-positions@8.0.3(postcss@8.5.26):
+ postcss-normalize-positions@8.0.4(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-value-parser: 4.2.0
- postcss-normalize-repeat-style@8.0.3(postcss@8.5.26):
+ postcss-normalize-repeat-style@8.0.4(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-value-parser: 4.2.0
- postcss-normalize-string@8.0.3(postcss@8.5.26):
+ postcss-normalize-string@8.0.4(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-value-parser: 4.2.0
- postcss-normalize-timing-functions@8.0.3(postcss@8.5.26):
+ postcss-normalize-timing-functions@8.0.4(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-value-parser: 4.2.0
- postcss-normalize-unicode@8.0.3(postcss@8.5.26):
+ postcss-normalize-unicode@8.0.4(postcss@8.5.26):
dependencies:
browserslist: 4.28.8
postcss: 8.5.26
postcss-value-parser: 4.2.0
- postcss-normalize-url@8.0.3(postcss@8.5.26):
+ postcss-normalize-url@8.0.4(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-value-parser: 4.2.0
- postcss-normalize-whitespace@8.0.3(postcss@8.5.26):
+ postcss-normalize-whitespace@8.0.4(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-value-parser: 4.2.0
- postcss-ordered-values@8.0.3(postcss@8.5.26):
+ postcss-ordered-values@8.0.4(postcss@8.5.26):
dependencies:
- cssnano-utils: 6.0.3(postcss@8.5.26)
+ cssnano-utils: 6.0.4(postcss@8.5.26)
postcss: 8.5.26
postcss-value-parser: 4.2.0
- postcss-reduce-initial@8.0.3(postcss@8.5.26):
+ postcss-reduce-initial@8.0.4(postcss@8.5.26):
dependencies:
browserslist: 4.28.8
caniuse-api: 4.0.0
postcss: 8.5.26
- postcss-reduce-transforms@8.0.3(postcss@8.5.26):
+ postcss-reduce-transforms@8.0.4(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-value-parser: 4.2.0
@@ -11580,13 +11572,13 @@ snapshots:
cssesc: 3.0.0
util-deprecate: 1.0.2
- postcss-svgo@8.0.4(postcss@8.5.26):
+ postcss-svgo@8.0.5(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-value-parser: 4.2.0
svgo: 4.0.2
- postcss-unique-selectors@8.0.3(postcss@8.5.26):
+ postcss-unique-selectors@8.0.4(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.5
@@ -12208,7 +12200,7 @@ snapshots:
dependencies:
min-indent: 1.0.1
- stylehacks@8.0.3(postcss@8.5.26):
+ stylehacks@8.0.4(postcss@8.5.26):
dependencies:
browserslist: 4.28.8
postcss: 8.5.26
@@ -12511,12 +12503,6 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
- update-browserslist-db@1.2.3(browserslist@4.28.7):
- dependencies:
- browserslist: 4.28.7
- escalade: 3.2.0
- picocolors: 1.1.1
-
update-browserslist-db@1.3.1(browserslist@4.28.8):
dependencies:
browserslist: 4.28.8
@@ -12567,7 +12553,7 @@ snapshots:
extsprintf: 1.4.1
optional: true
- vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0):
+ vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -12579,11 +12565,11 @@ snapshots:
esbuild: 0.28.2
fsevents: 2.3.3
jiti: 2.7.0
- less: 4.8.1
+ less: 4.9.0
sass: 1.102.0
yaml: 2.9.0
- vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0):
+ vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -12595,11 +12581,11 @@ snapshots:
esbuild: 0.28.1
fsevents: 2.3.3
jiti: 2.7.0
- less: 4.8.1
+ less: 4.9.0
sass: 1.102.0
yaml: 2.9.0
- vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0):
+ vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -12611,11 +12597,11 @@ snapshots:
esbuild: 0.28.2
fsevents: 2.3.3
jiti: 2.7.0
- less: 4.8.1
+ less: 4.9.0
sass: 1.102.0
yaml: 2.9.0
- vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0):
+ vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -12627,14 +12613,14 @@ snapshots:
esbuild: 0.28.1
fsevents: 2.3.3
jiti: 1.21.7
- less: 4.8.1
+ less: 4.9.0
sass: 1.102.0
yaml: 2.9.0
- vitest@4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)):
+ vitest@4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.4
- '@vitest/mocker': 4.1.4(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))
+ '@vitest/mocker': 4.1.4(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.4
'@vitest/runner': 4.1.4
'@vitest/snapshot': 4.1.4
@@ -12651,7 +12637,7 @@ snapshots:
tinyexec: 1.1.1
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
- vite: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)
+ vite: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.19.17
@@ -12660,10 +12646,10 @@ snapshots:
transitivePeerDependencies:
- msw
- vitest@4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)):
+ vitest@4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.4
- '@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))
+ '@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.4
'@vitest/runner': 4.1.4
'@vitest/snapshot': 4.1.4
@@ -12680,7 +12666,7 @@ snapshots:
tinyexec: 1.1.1
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
- vite: 8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)
+ vite: 8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.19.17
@@ -12689,10 +12675,10 @@ snapshots:
transitivePeerDependencies:
- msw
- vitest@4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)):
+ vitest@4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.4
- '@vitest/mocker': 4.1.4(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))
+ '@vitest/mocker': 4.1.4(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.4
'@vitest/runner': 4.1.4
'@vitest/snapshot': 4.1.4
@@ -12709,7 +12695,7 @@ snapshots:
tinyexec: 1.1.1
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
- vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)
+ vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.12.2
@@ -12718,10 +12704,10 @@ snapshots:
transitivePeerDependencies:
- msw
- vitest@4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)):
+ vitest@4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.4
- '@vitest/mocker': 4.1.4(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))
+ '@vitest/mocker': 4.1.4(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.4
'@vitest/runner': 4.1.4
'@vitest/snapshot': 4.1.4
@@ -12738,7 +12724,7 @@ snapshots:
tinyexec: 1.1.1
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
- vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)
+ vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.12.2