@lingxia/page-runtime 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
- export { ensurePageBridgeSubscription, getPageActions, getPageSnapshot, getPageStateInfo, subscribePageData, subscribePageSnapshot, type ActionMap, type Snapshot, } from "./shared/runtime.js";
2
- export { getPageChromeLayout, installPageChromeRuntime, subscribePageChromeLayout, type LxPageChrome, type PageChromeLayoutListener, type PageChromeLayoutSnapshot, type PageChromeRect, } from "./page-chrome.js";
1
+ export { ensurePageBridgeSubscription, getPageActions, getPageSnapshot, isPageReady, subscribePageSnapshot, whenPageReady, type ActionMap, type PageActions, type DeepReadonly, type Snapshot, } from "./shared/runtime.js";
2
+ export { waitForPageState } from "./shared/startup.js";
3
+ export { installPageChromeRuntime, type LxPageChrome, type PageChromeLayoutSnapshot, type PageChromeRect, } from "./page-chrome.js";
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
- export { ensurePageBridgeSubscription, getPageActions, getPageSnapshot, getPageStateInfo, subscribePageData, subscribePageSnapshot, } from "./shared/runtime.js";
2
- export { getPageChromeLayout, installPageChromeRuntime, subscribePageChromeLayout, } from "./page-chrome.js";
1
+ export { ensurePageBridgeSubscription, getPageActions, getPageSnapshot, isPageReady, subscribePageSnapshot, whenPageReady, } from "./shared/runtime.js";
2
+ export { waitForPageState } from "./shared/startup.js";
3
+ export { installPageChromeRuntime, } from "./page-chrome.js";
@@ -22,7 +22,6 @@ export interface PageChromeLayoutSnapshot {
22
22
  export interface LxPageChrome {
23
23
  readonly layout: PageChromeLayoutSnapshot;
24
24
  }
25
- export type PageChromeLayoutListener = (layout: PageChromeLayoutSnapshot) => void;
26
25
  declare global {
27
26
  interface Window {
28
27
  readonly lxPageChrome: LxPageChrome;
@@ -32,10 +31,6 @@ declare global {
32
31
  }
33
32
  }
34
33
  export declare function shouldApplyPageChromeRevision(currentRevision: number, nextRevision: number): boolean;
35
- /** Read the latest realized page-chrome layout synchronously. */
36
- export declare function getPageChromeLayout(): PageChromeLayoutSnapshot;
37
- /** Subscribe to realized page-chrome layout changes. */
38
- export declare function subscribePageChromeLayout(listener: PageChromeLayoutListener): () => void;
39
34
  /** Ensure browser previews have the same synchronous contract as native pages. */
40
35
  export declare function installPageChromeRuntime(): LxPageChrome | undefined;
41
36
  declare global {
@@ -13,23 +13,13 @@ function projectPageChromeLayout(layout) {
13
13
  root?.style.setProperty("--lx-page-chrome-top-inset", `${layout.topInset}px`);
14
14
  root?.style.setProperty("--lx-page-chrome-bottom-inset", `${layout.bottomInset}px`);
15
15
  root?.style.setProperty("--lx-page-chrome-capsule-inline-end-inset", `${layout.capsuleInlineEndInset}px`);
16
- }
17
- /** Read the latest realized page-chrome layout synchronously. */
18
- export function getPageChromeLayout() {
19
- if (typeof window === "undefined")
20
- return initialLayout;
21
- return installPageChromeRuntime()?.layout ?? initialLayout;
22
- }
23
- /** Subscribe to realized page-chrome layout changes. */
24
- export function subscribePageChromeLayout(listener) {
25
- if (typeof window === "undefined")
26
- return () => { };
27
- installPageChromeRuntime();
28
- const handleChange = (event) => {
29
- listener(event.detail);
30
- };
31
- window.addEventListener("lxpagechromechange", handleChange);
32
- return () => window.removeEventListener("lxpagechromechange", handleChange);
16
+ // The capsule's box, so a header can clear it in CSS alone
17
+ // (`padding-top: calc(var(--lx-page-chrome-capsule-bottom) + 12px)`).
18
+ // All zero when the page has no capsule.
19
+ const capsule = layout.capsuleRect;
20
+ for (const edge of ["top", "right", "bottom", "left", "width", "height"]) {
21
+ root?.style.setProperty(`--lx-page-chrome-capsule-${edge}`, `${capsule ? capsule[edge] : 0}px`);
22
+ }
33
23
  }
34
24
  /** Ensure browser previews have the same synchronous contract as native pages. */
35
25
  export function installPageChromeRuntime() {
@@ -1,10 +1,48 @@
1
- import { type DataSubscriber, type StateInfo } from "@lingxia/bridge";
1
+ import { type LxStream } from "@lingxia/bridge";
2
2
  export type ActionMap = Record<string, (...args: never[]) => unknown>;
3
+ /**
4
+ * The View's side of a page action: at most one JSON payload; unary actions
5
+ * acknowledge completion, generators arrive as a stream handle.
6
+ */
7
+ export type PageActions<A extends ActionMap> = {
8
+ readonly [K in keyof A]: A[K] extends (...args: infer P) => infer R ? number extends P["length"] ? (payload?: unknown) => ViewActionResult<R> : P extends [] | [unknown] | [unknown?] ? (...args: P) => ViewActionResult<R> : (error: "a page action takes at most one JSON payload") => never : never;
9
+ };
10
+ type ViewActionResult<R> = R extends LxStream<any, any> ? R : R extends AsyncGenerator<infer T, infer TReturn, any> ? LxStream<T, TReturn> : Promise<Awaited<R>>;
3
11
  export type Snapshot = Record<string, unknown>;
12
+ /**
13
+ * What a View reads of its page's data: Logic owns it, so every level is
14
+ * readonly. Keep editable state (a form draft) in the View and send it with
15
+ * an action.
16
+ */
17
+ export type DeepReadonly<T> = T extends (...args: never[]) => unknown ? T : T extends object ? {
18
+ readonly [K in keyof T]: DeepReadonly<T[K]>;
19
+ } : T;
4
20
  export type Listener = () => void;
5
21
  export declare function ensurePageBridgeSubscription(): void;
6
22
  export declare function subscribePageSnapshot(listener: Listener): () => void;
7
- export declare function subscribePageData(callback: DataSubscriber): () => void;
23
+ /** Whether the page's first state has arrived. Flips once per document. */
24
+ export declare function isPageReady(): boolean;
25
+ /**
26
+ * Resolves once the page's first state has arrived — the host pushes it at
27
+ * bridge-ready, before `onLoad`, carrying `Page({ data })`'s defaults — so a
28
+ * page can mount with its data whole. With `timeoutMs`, rejects if Logic has
29
+ * not delivered it by then (it failed to load, or threw first); `null` waits
30
+ * without limit.
31
+ */
32
+ export declare function whenPageReady(options?: {
33
+ timeoutMs?: number | null;
34
+ }): Promise<void>;
8
35
  export declare function getPageSnapshot<TData = Snapshot>(): TData;
9
- export declare function getPageActions<TActions extends ActionMap>(): TActions;
10
- export declare function getPageStateInfo(): StateInfo;
36
+ /**
37
+ * The page's actions: one object for the whole page, so it is a stable
38
+ * dependency. Built on first use — the page's action names arrive with its
39
+ * bridge metadata, after the page module has evaluated.
40
+ */
41
+ export declare function getPageActions<TActions extends ActionMap>(): PageActions<TActions>;
42
+ declare global {
43
+ interface Window {
44
+ /** Automation-only; see `invokePageActionForAutomation`. */
45
+ __lxInvokePageAction?: (name: string, payload?: unknown) => Promise<unknown>;
46
+ }
47
+ }
48
+ export {};
@@ -2,7 +2,16 @@ let snapshot = {};
2
2
  let stateInfo = { rev: -1, initial: true };
3
3
  let subscribed = false;
4
4
  let subscribeRetryTimer = null;
5
+ let snapshotRetryTimer = null;
6
+ let snapshotRetries = 0;
7
+ /**
8
+ * The host pushes a page's first state at bridge-ready, so the request is only
9
+ * a fallback: a few tries, backing off, then stop. A page with no Logic never
10
+ * answers, and must not be asked for the life of the page.
11
+ */
12
+ const MAX_SNAPSHOT_RETRIES = 3;
5
13
  let initialSnapshotResolved = false;
14
+ let actions = null;
6
15
  let snapshotRequestInFlight = false;
7
16
  const listeners = new Set();
8
17
  function notifyListeners() {
@@ -15,8 +24,26 @@ function notifyListeners() {
15
24
  }
16
25
  });
17
26
  }
27
+ /**
28
+ * In a dev session the page data is frozen, so a View that writes to it fails
29
+ * at the write instead of drifting from Logic until the next update. Each
30
+ * push is a fresh copy, so nothing else holds these objects.
31
+ */
32
+ function isDevSession() {
33
+ return typeof window !== "undefined" && window.__LX_BRIDGE_CFG?.dev === true;
34
+ }
35
+ function deepFreeze(value) {
36
+ if (!value || typeof value !== "object" || Object.isFrozen(value))
37
+ return;
38
+ Object.freeze(value);
39
+ for (const key of Object.keys(value)) {
40
+ deepFreeze(value[key]);
41
+ }
42
+ }
18
43
  function updateSnapshot(next, info) {
19
44
  snapshot = next && typeof next === "object" ? next : {};
45
+ if (isDevSession())
46
+ deepFreeze(snapshot);
20
47
  stateInfo = info;
21
48
  notifyListeners();
22
49
  }
@@ -28,11 +55,29 @@ function scheduleSubscribeRetry() {
28
55
  ensurePageBridgeSubscription();
29
56
  }, 10);
30
57
  }
58
+ /**
59
+ * Retry the snapshot request itself. The subscription is already in place, so
60
+ * retrying it (as this used to) returned at once and never asked again; the
61
+ * page then relied on the host's own push at bridge-ready.
62
+ */
63
+ function scheduleSnapshotRetry() {
64
+ if (snapshotRetryTimer !== null || snapshotRetries >= MAX_SNAPSHOT_RETRIES)
65
+ return;
66
+ snapshotRetries += 1;
67
+ snapshotRetryTimer = setTimeout(() => {
68
+ snapshotRetryTimer = null;
69
+ requestInitialSnapshot(window.LingXiaBridge);
70
+ }, 250 * 2 ** (snapshotRetries - 1));
71
+ }
31
72
  function requestInitialSnapshot(bridge) {
73
+ if (stateInfo.rev >= 0)
74
+ initialSnapshotResolved = true;
32
75
  if (initialSnapshotResolved || snapshotRequestInFlight)
33
76
  return;
34
- if (!bridge?.raw?.call)
77
+ if (!bridge?.raw?.call) {
78
+ scheduleSnapshotRetry();
35
79
  return;
80
+ }
36
81
  snapshotRequestInFlight = true;
37
82
  bridge
38
83
  .raw.call("state.getSnapshot", { scope: "page" })
@@ -40,7 +85,7 @@ function requestInitialSnapshot(bridge) {
40
85
  initialSnapshotResolved = true;
41
86
  })
42
87
  .catch(() => {
43
- scheduleSubscribeRetry();
88
+ scheduleSnapshotRetry();
44
89
  })
45
90
  .finally(() => {
46
91
  snapshotRequestInFlight = false;
@@ -68,40 +113,69 @@ export function subscribePageSnapshot(listener) {
68
113
  listeners.delete(listener);
69
114
  };
70
115
  }
71
- export function subscribePageData(callback) {
72
- if (typeof callback !== "function")
73
- return () => { };
116
+ /** Whether the page's first state has arrived. Flips once per document. */
117
+ export function isPageReady() {
74
118
  ensurePageBridgeSubscription();
75
- if (stateInfo.rev >= 0) {
76
- callback(snapshot, { rev: stateInfo.rev, initial: true });
77
- }
78
- return subscribePageSnapshot(() => {
79
- callback(snapshot, stateInfo);
119
+ return stateInfo.rev >= 0;
120
+ }
121
+ /**
122
+ * Resolves once the page's first state has arrived — the host pushes it at
123
+ * bridge-ready, before `onLoad`, carrying `Page({ data })`'s defaults — so a
124
+ * page can mount with its data whole. With `timeoutMs`, rejects if Logic has
125
+ * not delivered it by then (it failed to load, or threw first); `null` waits
126
+ * without limit.
127
+ */
128
+ export function whenPageReady(options = {}) {
129
+ if (isPageReady())
130
+ return Promise.resolve();
131
+ const timeoutMs = options.timeoutMs === undefined ? 10000 : options.timeoutMs;
132
+ return new Promise((resolve, reject) => {
133
+ let timer = null;
134
+ const check = () => {
135
+ if (stateInfo.rev < 0)
136
+ return;
137
+ if (timer !== null)
138
+ clearTimeout(timer);
139
+ listeners.delete(check);
140
+ resolve();
141
+ };
142
+ if (timeoutMs !== null) {
143
+ timer = setTimeout(() => {
144
+ listeners.delete(check);
145
+ reject(new Error(`Page Logic did not deliver the page state within ${timeoutMs} ms`));
146
+ }, timeoutMs);
147
+ }
148
+ listeners.add(check);
80
149
  });
81
150
  }
82
151
  export function getPageSnapshot() {
83
152
  ensurePageBridgeSubscription();
84
153
  return snapshot;
85
154
  }
155
+ /**
156
+ * The page's actions: one object for the whole page, so it is a stable
157
+ * dependency. Built on first use — the page's action names arrive with its
158
+ * bridge metadata, after the page module has evaluated.
159
+ */
86
160
  export function getPageActions() {
87
- const actions = {};
161
+ if (actions)
162
+ return actions;
88
163
  const bridge = window.__pageBridge;
89
164
  if (!bridge?.__names) {
90
- return actions;
165
+ throw new Error("Page actions are not ready. Read useLxPage() during component setup/render, or getPage() after pageReady(); pass actions into shared helpers.");
91
166
  }
167
+ const built = {};
92
168
  for (const name of bridge.__names) {
93
169
  if (typeof name !== "string")
94
170
  continue;
95
171
  const fn = getOrCreatePageAction(bridge, name);
96
172
  if (typeof fn === "function") {
97
- actions[name] = fn;
173
+ built[name] = fn;
98
174
  }
99
175
  }
176
+ actions = built;
100
177
  return actions;
101
178
  }
102
- export function getPageStateInfo() {
103
- return stateInfo;
104
- }
105
179
  function getOrCreatePageAction(bridge, name) {
106
180
  const existing = bridge[name];
107
181
  if (typeof existing === "function") {
@@ -114,7 +188,9 @@ function getOrCreatePageAction(bridge, name) {
114
188
  }
115
189
  function resolvePageActionMode(bridge, name) {
116
190
  const mode = bridge.__modes?.[name];
117
- return mode === "call" || mode === "stream" ? mode : "notify";
191
+ if (mode === "call" || mode === "stream")
192
+ return mode;
193
+ throw new Error(`Invalid bridge mode for page action '${name}'; regenerate the page with the current CLI`);
118
194
  }
119
195
  function definePageBridgeAction(name, mode) {
120
196
  function action(...args) {
@@ -133,7 +209,7 @@ function definePageBridgeAction(name, mode) {
133
209
  return handle;
134
210
  }
135
211
  if (mode === "call") {
136
- const promise = bridge.raw.call(name, payload);
212
+ const promise = callUnaryPageAction(bridge, name, payload);
137
213
  if (promise && typeof promise.catch === "function") {
138
214
  promise.catch((err) => {
139
215
  console.warn(`[PageFunc] ${name} failed:`, err instanceof Error ? err.message : err);
@@ -141,8 +217,7 @@ function definePageBridgeAction(name, mode) {
141
217
  }
142
218
  return promise;
143
219
  }
144
- bridge.raw.notify(name, payload);
145
- return undefined;
220
+ throw new Error(`Invalid bridge mode for page action '${name}'`);
146
221
  }
147
222
  Object.assign(action, {
148
223
  __logicFunc: true,
@@ -151,40 +226,87 @@ function definePageBridgeAction(name, mode) {
151
226
  });
152
227
  return action;
153
228
  }
229
+ /**
230
+ * The one wire path of a unary action. It has no bridge deadline: an action
231
+ * may wait for a person or poll for minutes, and settles when Logic settles
232
+ * or the document goes away.
233
+ */
234
+ async function callUnaryPageAction(bridge, name, payload, dropWhenGone = true) {
235
+ // Native components fire events before the page's first state arrives,
236
+ // and the host refuses calls until then: hold the call instead.
237
+ if (!isPageReady())
238
+ await whenPageReady({ timeoutMs: null });
239
+ try {
240
+ return await bridge.raw.call(name, payload, { timeoutMs: 0 });
241
+ }
242
+ catch (error) {
243
+ // Once the page has had its state, a not-ready answer means the host
244
+ // ended this document's session: the page has left, and a native
245
+ // component's late event has no one to report to. Drop it quietly.
246
+ // Automation has a caller waiting, so it gets the error.
247
+ if (dropWhenGone && error?.code === "BRIDGE_NOT_READY") {
248
+ return new Promise(() => { });
249
+ }
250
+ throw error;
251
+ }
252
+ }
253
+ /**
254
+ * A DOM or framework event, in this realm or another. A JSON payload never
255
+ * carries methods, so event methods tell the two apart.
256
+ */
257
+ function isEventLike(value) {
258
+ if (typeof Event !== "undefined" && value instanceof Event)
259
+ return true;
260
+ if (!value || typeof value !== "object")
261
+ return false;
262
+ const event = value;
263
+ return typeof event.preventDefault === "function" && typeof event.stopPropagation === "function";
264
+ }
265
+ const PAGE_ACTIONS_NOT_READY = "PAGE_ACTIONS_NOT_READY";
266
+ function pageActionError(code, message) {
267
+ return Object.assign(new Error(message), { code });
268
+ }
269
+ /**
270
+ * Automation entry (`PageDriver.action`): invoke one unary action of this
271
+ * page with a JSON payload, as the View would. The payload is passed as is —
272
+ * no DOM-event repackaging.
273
+ */
274
+ function invokePageActionForAutomation(name, payload) {
275
+ if (typeof name !== "string" || name === "") {
276
+ return Promise.reject(pageActionError("BRIDGE_MALFORMED_MESSAGE", "Page action name must be a non-empty string"));
277
+ }
278
+ const metadata = window.__pageBridge;
279
+ const bridge = window.LingXiaBridge;
280
+ if (!Array.isArray(metadata?.__names) || !bridge?.raw) {
281
+ return Promise.reject(pageActionError(PAGE_ACTIONS_NOT_READY, "Page actions are not ready yet"));
282
+ }
283
+ if (!metadata.__names.includes(name)) {
284
+ const known = metadata.__names.length ? metadata.__names.join(", ") : "none";
285
+ return Promise.reject(pageActionError("BRIDGE_METHOD_NOT_FOUND", `'${name}' is not an action of this page (actions: ${known})`));
286
+ }
287
+ if (metadata.__modes?.[name] !== "call") {
288
+ return Promise.reject(pageActionError("PAGE_ACTION_NOT_UNARY", `Page action '${name}' is a stream action; only unary actions can be invoked`));
289
+ }
290
+ return callUnaryPageAction(bridge, name, payload, false);
291
+ }
292
+ if (typeof window !== "undefined") {
293
+ Object.defineProperty(window, "__lxInvokePageAction", {
294
+ value: invokePageActionForAutomation,
295
+ configurable: true,
296
+ });
297
+ }
154
298
  function filterPayload(name, args) {
155
299
  const clean = [];
156
300
  for (const value of args) {
157
- // CustomEvent carries serializable data on `.detail`, but the DOM Event
158
- // wrapper itself is not portable across the bridge. Repackage as a plain
159
- // `{detail, type}` so page actions bound directly to DOM listeners (e.g.
160
- // `onVideoEnded={action}`) keep the familiar `event.detail` shape without
161
- // forwarding the live Event instance. Without this rewrite the bare Event
162
- // was stripped wholesale, producing `event = undefined` on the receiving
163
- // side — surfaced in the showcase as "video ended undefined".
164
- if (typeof CustomEvent !== "undefined" && value instanceof CustomEvent) {
165
- clean.push({ type: value.type, detail: value.detail });
166
- continue;
167
- }
168
- // Some framework wrappers / WebView realms do not preserve
169
- // `instanceof CustomEvent`, but still expose the portable event payload
170
- // shape. Keep it before the generic Event stripping path so page actions
171
- // receive `event.detail` consistently.
172
- const maybeEvent = value;
173
- if (maybeEvent && typeof maybeEvent === "object" && typeof maybeEvent.type === "string" && "detail" in maybeEvent) {
174
- clean.push({
175
- type: maybeEvent.type,
176
- detail: maybeEvent.detail,
177
- });
178
- continue;
179
- }
180
- // Generic Event / event-like objects with non-serializable methods stay
181
- // stripped — there's no portable payload to extract.
182
- if (value instanceof Event)
183
- continue;
184
- if (value &&
185
- typeof value === "object" &&
186
- "stopPropagation" in value &&
187
- typeof value.stopPropagation === "function") {
301
+ // A DOM event bound straight to an action (`onVideoEnded={action}`)
302
+ // crosses as its portable `{ type, detail }`; one without `detail`
303
+ // carries nothing portable. A business payload passes as is, whatever
304
+ // its field names.
305
+ if (isEventLike(value)) {
306
+ const event = value;
307
+ if (typeof event.type === "string" && "detail" in event) {
308
+ clean.push({ type: event.type, detail: event.detail });
309
+ }
188
310
  continue;
189
311
  }
190
312
  clean.push(value);
@@ -0,0 +1,2 @@
1
+ /** Mount with complete initial data; a delayed startup stays recoverable. */
2
+ export declare function waitForPageState(): Promise<void>;
@@ -0,0 +1,25 @@
1
+ import { renderPageFault } from "@lingxia/bridge";
2
+ import { isPageReady, whenPageReady } from "./runtime.js";
3
+ let pending;
4
+ /** Mount with complete initial data; a delayed startup stays recoverable. */
5
+ export function waitForPageState() {
6
+ if (isPageReady())
7
+ return Promise.resolve();
8
+ return pending ?? (pending = wait().finally(() => { pending = undefined; }));
9
+ }
10
+ async function wait() {
11
+ const delay = 10000;
12
+ const panel = {};
13
+ const timer = setTimeout(() => {
14
+ const reason = `Page Logic has not delivered the page state after ${delay} ms`;
15
+ console.error(`[LingXia] ${location.pathname}: ${reason}`);
16
+ panel.dismiss = renderPageFault(location.pathname, reason);
17
+ }, delay);
18
+ try {
19
+ await whenPageReady({ timeoutMs: null });
20
+ }
21
+ finally {
22
+ clearTimeout(timer);
23
+ panel.dismiss?.();
24
+ }
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lingxia/page-runtime",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "private": false,
5
5
  "description": "Shared page runtime core for LingXia lxapp frameworks",
6
6
  "type": "module",
@@ -17,10 +17,11 @@
17
17
  "build": "npm run clean && npm --prefix ../lingxia-bridge run build:types && tsc -p tsconfig.json",
18
18
  "clean": "rm -rf dist",
19
19
  "test:runtime": "npm run build && node tests/page-chrome-revision.mjs",
20
- "prepublishOnly": "npm run build"
20
+ "prepublishOnly": "npm run build",
21
+ "test": "rolldown src/shared/runtime.ts --file dist/test/runtime.mjs --format esm && node ./test-support/test-runtime.mjs"
21
22
  },
22
23
  "dependencies": {
23
- "@lingxia/bridge": "~0.18.0"
24
+ "@lingxia/bridge": "~0.19.0"
24
25
  },
25
26
  "devDependencies": {
26
27
  "@lingxia/bridge": "file:../lingxia-bridge",