akanjs 2.4.1-rc.2 → 2.4.1-rc.3

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/client/device.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  "use client";
2
2
  import type { RefObject } from "react";
3
- import { debugFrame } from "./frameDebug";
4
3
  import type {
5
4
  CapacitorDeviceInfo,
6
5
  CapacitorHapticsModule,
7
6
  CapacitorKeyboardInfo,
8
7
  CapacitorKeyboardModule,
9
8
  } from "./capacitor";
9
+ import { debugFrame } from "./frameDebug";
10
10
 
11
11
  type DeviceInfo = CapacitorDeviceInfo;
12
12
  type Keyboard = CapacitorKeyboardModule["Keyboard"];
package/client/router.ts CHANGED
@@ -112,7 +112,10 @@ const normalizeRouteManifestPath = (href: string, prefix = "") => {
112
112
  return segments.length === 0 ? "/" : `/${segments.join("/")}`;
113
113
  };
114
114
 
115
- export const normalizeDeepLinkHref = (href: string, origin = globalThis.window?.location?.origin ?? "http://localhost") => {
115
+ export const normalizeDeepLinkHref = (
116
+ href: string,
117
+ origin = globalThis.window?.location?.origin ?? "http://localhost",
118
+ ) => {
116
119
  const url = new URL(href, origin);
117
120
  if (url.protocol === "http:" || url.protocol === "https:") return `${url.pathname}${url.search}${url.hash}`;
118
121
  const hostPath = url.hostname ? `/${url.hostname}` : "";
@@ -268,7 +271,11 @@ class Router {
268
271
  const state = (history.state ?? {}) as Record<string, unknown>;
269
272
  const akanState = (state.__akanRouter ?? {}) as { idx?: number };
270
273
  this.#historyIdx = Number.isInteger(akanState.idx) ? (akanState.idx as number) : this.#historyIdx;
271
- history.replaceState({ ...state, __akanRouter: { ...akanState, idx: this.#historyIdx } }, "", globalThis.window.location.href);
274
+ history.replaceState(
275
+ { ...state, __akanRouter: { ...akanState, idx: this.#historyIdx } },
276
+ "",
277
+ globalThis.window.location.href,
278
+ );
272
279
  if (!globalThis.window.addEventListener) return;
273
280
  globalThis.window.addEventListener("popstate", (event) => {
274
281
  const nextState = ((event.state as Record<string, unknown> | null)?.__akanRouter ?? {}) as { idx?: number };
@@ -336,7 +343,9 @@ class Router {
336
343
  }
337
344
  const stack = this.#routePaths.size > 0 ? this.#getExistingSegmentStack(path) : [path];
338
345
  const baseStack =
339
- stack.length > 1 || this.#indexPath === path || stack.includes(this.#indexPath) ? stack : [this.#indexPath, ...stack];
346
+ stack.length > 1 || this.#indexPath === path || stack.includes(this.#indexPath)
347
+ ? stack
348
+ : [this.#indexPath, ...stack];
340
349
  const dedupedStack = baseStack.filter((candidate, index) => baseStack.indexOf(candidate) === index);
341
350
  const target = `${path}${search ? `?${search}` : ""}${hash ? `#${hash}` : ""}`;
342
351
  return dedupedStack.map((candidate, index) => (index === dedupedStack.length - 1 ? target : candidate));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.4.1-rc.2",
3
+ "version": "2.4.1-rc.3",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -165,6 +165,11 @@
165
165
  "types": "./types/server/rscWorker.d.ts",
166
166
  "import": "./server/rscWorker.tsx",
167
167
  "default": "./server/rscWorker.tsx"
168
+ },
169
+ "./server/memoryLimit": {
170
+ "types": "./types/server/memoryLimit.d.ts",
171
+ "import": "./server/memoryLimit.ts",
172
+ "default": "./server/memoryLimit.ts"
168
173
  }
169
174
  },
170
175
  "dependencies": {
@@ -105,3 +105,19 @@ AKAN_RSC_WORKER_RECYCLE_GRACE_MS=5000
105
105
  ```
106
106
 
107
107
  Validate that recycle happens only when `rscPendingRenderCount` is `0`, and that the new `rscWorkerPid` becomes ready before traffic continues.
108
+
109
+ ## Dev Builder Recycle
110
+
111
+ The dev builder has the same problem for a different reason: `Bun.build` retains native bundler arenas
112
+ that no GC reclaims, so the process only returns that memory by exiting. `AkanAppHost` therefore
113
+ recycles it — gracefully, after its queues drain and once it has stayed quiet — past a ceiling:
114
+
115
+ ```sh
116
+ AKAN_BUILDER_MAX_RSS_MB=1200 # 0 leaves the builder unbounded; default is 1200 in dev
117
+ AKAN_BUILDER_MAX_RSS=1200mb # same ceiling with a unit suffix
118
+ ```
119
+
120
+ Both resolve through `MemoryLimit.resolveMaxRssBytes`, so `AKAN_MEMORY_LIMIT` or a cgroup limit also
121
+ applies (the builder takes 35% of it, the RSC worker 55%). Look for `recycling builder pid=…` followed
122
+ by `exiting for recycle` and `announced boot state after recycle`; the last line is what re-points a
123
+ running backend at the replacement's artifact.
package/server/akanApp.ts CHANGED
@@ -1197,7 +1197,7 @@ export class AkanApp {
1197
1197
  const reader = stream.getReader();
1198
1198
  const decoder = new TextDecoder();
1199
1199
  try {
1200
- while (true) {
1200
+ for (;;) {
1201
1201
  const { done, value } = await reader.read();
1202
1202
  if (done) break;
1203
1203
  const text = decoder.decode(value, { stream: true });
@@ -1213,7 +1213,7 @@ export class AkanApp {
1213
1213
 
1214
1214
  #writeChildOutput(idx: number, role: AkanChildRole, type: "stdout" | "stderr", bufferKey: string, text: string) {
1215
1215
  let buffered = `${this.#childOutputBuffers.get(bufferKey) ?? ""}${text}`;
1216
- while (true) {
1216
+ for (;;) {
1217
1217
  const newlineIdx = buffered.indexOf("\n");
1218
1218
  if (newlineIdx === -1) break;
1219
1219
  const line = buffered.slice(0, newlineIdx + 1);
@@ -9,11 +9,21 @@ export interface BuildRouteResultPayload {
9
9
  generation?: number;
10
10
  }
11
11
 
12
+ /**
13
+ * Marks a frontend payload that re-announces a freshly booted artifact rather than reporting an edit.
14
+ * A recycled builder rebuilds every artifact from scratch, but a running backend read
15
+ * `base-artifact.json` once at boot and never re-reads it, so the new state has to be pushed. The
16
+ * host drops such a payload when the hashed output did not actually move, which keeps a clean
17
+ * recycle invisible to connected browsers.
18
+ */
19
+ export type BuilderStateReason = "builder-recycle";
20
+
12
21
  export interface CssPayload {
13
22
  cssAssets: Record<string, { cssUrl: string; cssRelPath: string }>;
14
23
  cssBase64ByUrl: Record<string, string>;
15
24
  generation?: number;
16
25
  changedFiles?: string[];
26
+ reason?: BuilderStateReason;
17
27
  }
18
28
 
19
29
  export type DevChangeRole = "server" | "client" | "shared" | "barrel" | "config" | "css";
@@ -71,11 +81,34 @@ export type BuilderCsrRes =
71
81
  | { type: "build-csr-res"; id: number; ok: true }
72
82
  | { type: "build-csr-res"; id: number; ok: false; error: string };
73
83
 
84
+ /**
85
+ * Asks the builder to finish its queued work and exit, which is the only way bundler memory is
86
+ * returned to the OS (see `BuilderMetrics`). The host's existing restart path brings up a
87
+ * replacement, so a graceful drain — rather than a kill — keeps a rebuild in flight from being
88
+ * truncated.
89
+ */
90
+ export type BuilderControl = { type: "builder-shutdown"; reason: string };
91
+
74
92
  export interface PagesBundlePayload {
75
93
  bundlePath: string;
76
94
  buildId: number;
77
95
  generation?: number;
78
96
  changedFiles?: string[];
97
+ reason?: BuilderStateReason;
98
+ }
99
+
100
+ /**
101
+ * `Bun.build` retains native bundler arenas the process never returns — `Bun.gc(true)` reclaims
102
+ * nothing and the JS heap stays flat while RSS climbs — so the builder's memory only comes back when
103
+ * it exits. It reports its own RSS here whenever its queues drain, and the dev host recycles it past
104
+ * a ceiling to turn unbounded growth into a bounded sawtooth.
105
+ */
106
+ export interface BuilderMetrics {
107
+ rssBytes: number;
108
+ /** The builder's newest generation; 0 until it has processed a watch batch since spawning. */
109
+ generation: number;
110
+ /** Work items completed since this builder spawned, so a host can require real work before recycling. */
111
+ workCount: number;
79
112
  }
80
113
 
81
114
  export type BuilderEvent =
@@ -90,6 +123,7 @@ export type BuilderEvent =
90
123
  }
91
124
  | { type: "css-updated"; data: CssPayload }
92
125
  | { type: "pages-updated"; data: PagesBundlePayload }
93
- | { type: "build-status"; data: DevBuildStatus };
126
+ | { type: "build-status"; data: DevBuildStatus }
127
+ | { type: "builder-metrics"; data: BuilderMetrics };
94
128
 
95
- export type BuilderMessage = BuilderReq | BuilderRes | BuilderCsrReq | BuilderCsrRes | BuilderEvent;
129
+ export type BuilderMessage = BuilderReq | BuilderRes | BuilderCsrReq | BuilderCsrRes | BuilderControl | BuilderEvent;
@@ -0,0 +1,77 @@
1
+ import fs from "node:fs";
2
+
3
+ /**
4
+ * Where a process's RSS ceiling comes from, shared by every host that recycles a child to bound it.
5
+ *
6
+ * A budget can be declared explicitly, discovered from the container, or absent altogether, so each
7
+ * host asks for a fraction of whatever limit it can find and supplies its own fallback for the last
8
+ * case. Kept as its own module rather than living on a host class because both `akanjs/server` and
9
+ * the dev host in `@akanjs/devkit` need it, and neither should import the other's stack to get it.
10
+ */
11
+ export class MemoryLimit {
12
+ static readonly #cgroupLimitFiles = ["/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory/memory.limit_in_bytes"];
13
+ /** Host-level cgroup files report effectively-unlimited sentinels; anything this large means "no limit". */
14
+ static readonly #unlimitedSentinelBytes = 1024 ** 5;
15
+
16
+ static parsePositiveIntEnv(name: string): number | null {
17
+ const parsed = Number.parseInt(process.env[name] ?? "", 10);
18
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
19
+ }
20
+
21
+ /** Reads a byte count with an optional unit suffix, e.g. `512mb`, `2GiB`, `1073741824`. */
22
+ static parseBytesEnv(name: string): number | null {
23
+ const value = process.env[name];
24
+ if (!value) return null;
25
+ const match = /^(\d+)(b|kb|kib|mb|mib|gb|gib)?$/i.exec(value.trim());
26
+ if (!match) return null;
27
+ const amount = Number.parseInt(match[1] ?? "", 10);
28
+ const unit = (match[2] ?? "b").toLowerCase();
29
+ if (!Number.isFinite(amount) || amount <= 0) return null;
30
+ if (unit === "gb" || unit === "gib") return amount * 1024 * 1024 * 1024;
31
+ if (unit === "mb" || unit === "mib") return amount * 1024 * 1024;
32
+ if (unit === "kb" || unit === "kib") return amount * 1024;
33
+ return amount;
34
+ }
35
+
36
+ static readCgroupBytes(): number | null {
37
+ for (const filePath of MemoryLimit.#cgroupLimitFiles) {
38
+ try {
39
+ if (!fs.existsSync(filePath)) continue;
40
+ const raw = fs.readFileSync(filePath, "utf8").trim();
41
+ if (!raw || raw === "max") continue;
42
+ const parsed = Number.parseInt(raw, 10);
43
+ if (Number.isFinite(parsed) && parsed > 0 && parsed < MemoryLimit.#unlimitedSentinelBytes) return parsed;
44
+ } catch {
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /**
51
+ * Resolves a ceiling from, in order: an explicit MiB env, an explicit byte env, a fraction of the
52
+ * container's memory limit, and finally the caller's fallback (`null` to leave the process
53
+ * unbounded).
54
+ */
55
+ static resolveMaxRssBytes({
56
+ megabytesEnv,
57
+ bytesEnv,
58
+ limitFraction,
59
+ fallbackBytes,
60
+ }: {
61
+ megabytesEnv: string;
62
+ bytesEnv: string;
63
+ limitFraction: number;
64
+ fallbackBytes: number | null;
65
+ }): number | null {
66
+ const explicitMb = MemoryLimit.parsePositiveIntEnv(megabytesEnv);
67
+ if (explicitMb) return explicitMb * 1024 * 1024;
68
+
69
+ const explicitBytes = MemoryLimit.parseBytesEnv(bytesEnv);
70
+ if (explicitBytes) return explicitBytes;
71
+
72
+ const memoryLimitBytes = MemoryLimit.parseBytesEnv("AKAN_MEMORY_LIMIT") ?? MemoryLimit.readCgroupBytes();
73
+ if (memoryLimitBytes) return Math.floor(memoryLimitBytes * limitFraction);
74
+
75
+ return fallbackBytes;
76
+ }
77
+ }
@@ -6,6 +6,7 @@ import type { AkanTheme } from "akanjs/fetch";
6
6
  import type { AkanMetricsReport } from "akanjs/service";
7
7
  import type { ClientManifest } from "./artifact";
8
8
  import type { RouteCacheInvalidation, RouteCacheRenderState } from "./cachePolicy";
9
+ import { MemoryLimit } from "./memoryLimit";
9
10
  import type { RscTraceMetadata, SsrLateRedirect } from "./ssrTypes";
10
11
  import type { BaseBuildArtifact, CssAsset } from "./types";
11
12
 
@@ -813,7 +814,7 @@ export class RscWorker {
813
814
  this.restartWhenIdle(`rss>${Math.round(maxRssBytes / 1024 / 1024)}MiB`);
814
815
  return;
815
816
  }
816
- const maxRenderCount = RscWorker.#parsePositiveIntEnv("AKAN_RSC_WORKER_MAX_RENDER_COUNT");
817
+ const maxRenderCount = MemoryLimit.parsePositiveIntEnv("AKAN_RSC_WORKER_MAX_RENDER_COUNT");
817
818
  if (maxRenderCount && (metrics.rscRenderCount ?? 0) >= maxRenderCount) {
818
819
  this.restartWhenIdle(`renderCount>${maxRenderCount}`);
819
820
  return;
@@ -824,46 +825,12 @@ export class RscWorker {
824
825
  }
825
826
  }
826
827
 
827
- static #parsePositiveIntEnv(name: string): number | null {
828
- const parsed = Number.parseInt(process.env[name] ?? "", 10);
829
- return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
830
- }
831
-
832
828
  static #isProductionRuntime(): boolean {
833
829
  return process.env.NODE_ENV === "production";
834
830
  }
835
831
 
836
- static #parseBytesEnv(name: string): number | null {
837
- const value = process.env[name];
838
- if (!value) return null;
839
- const match = /^(\d+)(b|kb|kib|mb|mib|gb|gib)?$/i.exec(value.trim());
840
- if (!match) return null;
841
- const amount = Number.parseInt(match[1] ?? "", 10);
842
- const unit = (match[2] ?? "b").toLowerCase();
843
- if (!Number.isFinite(amount) || amount <= 0) return null;
844
- if (unit === "gb" || unit === "gib") return amount * 1024 * 1024 * 1024;
845
- if (unit === "mb" || unit === "mib") return amount * 1024 * 1024;
846
- if (unit === "kb" || unit === "kib") return amount * 1024;
847
- return amount;
848
- }
849
-
850
- static #readCgroupMemoryLimitBytes(): number | null {
851
- for (const filePath of ["/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory/memory.limit_in_bytes"]) {
852
- try {
853
- if (!fs.existsSync(filePath)) continue;
854
- const raw = fs.readFileSync(filePath, "utf8").trim();
855
- if (!raw || raw === "max") continue;
856
- const parsed = Number.parseInt(raw, 10);
857
-
858
- if (Number.isFinite(parsed) && parsed > 0 && parsed < 1024 ** 5) return parsed;
859
- } catch {
860
- }
861
- }
862
- return null;
863
- }
864
-
865
832
  static #getRscRecycleGraceMs(): number {
866
- return RscWorker.#parsePositiveIntEnv("AKAN_RSC_WORKER_RECYCLE_GRACE_MS") ?? 5_000;
833
+ return MemoryLimit.parsePositiveIntEnv("AKAN_RSC_WORKER_RECYCLE_GRACE_MS") ?? 5_000;
867
834
  }
868
835
 
869
836
  /**
@@ -872,28 +839,25 @@ export class RscWorker {
872
839
  */
873
840
  static #getRscMaxReloads(): number | null {
874
841
  if (process.env.AKAN_RSC_WORKER_MAX_RELOADS !== undefined)
875
- return RscWorker.#parsePositiveIntEnv("AKAN_RSC_WORKER_MAX_RELOADS");
842
+ return MemoryLimit.parsePositiveIntEnv("AKAN_RSC_WORKER_MAX_RELOADS");
876
843
  return RscWorker.#isProductionRuntime() ? null : RscWorker.#devMaxReloads;
877
844
  }
878
845
 
879
846
  static #getRscMinRecycleIntervalMs(): number {
880
- return RscWorker.#parsePositiveIntEnv("AKAN_RSC_WORKER_MIN_RECYCLE_INTERVAL_MS") ?? 1_000;
847
+ return MemoryLimit.parsePositiveIntEnv("AKAN_RSC_WORKER_MIN_RECYCLE_INTERVAL_MS") ?? 1_000;
881
848
  }
882
849
 
883
850
  static #getRscMaxRssBytes(): number | null {
884
- const explicitMb = RscWorker.#parsePositiveIntEnv("AKAN_RSC_WORKER_MAX_RSS_MB");
885
- if (explicitMb) return explicitMb * 1024 * 1024;
851
+ return MemoryLimit.resolveMaxRssBytes({
852
+ megabytesEnv: "AKAN_RSC_WORKER_MAX_RSS_MB",
853
+ bytesEnv: "AKAN_RSC_WORKER_MAX_RSS",
854
+ limitFraction: 0.55,
886
855
 
887
- const explicitBytes = RscWorker.#parseBytesEnv("AKAN_RSC_WORKER_MAX_RSS");
888
- if (explicitBytes) return explicitBytes;
889
-
890
- const memoryLimitBytes = RscWorker.#parseBytesEnv("AKAN_MEMORY_LIMIT") ?? RscWorker.#readCgroupMemoryLimitBytes();
891
- if (memoryLimitBytes) return Math.floor(memoryLimitBytes * 0.55);
892
-
893
- return RscWorker.#isProductionRuntime() ? null : RscWorker.#devMaxRssBytes;
856
+ fallbackBytes: RscWorker.#isProductionRuntime() ? null : RscWorker.#devMaxRssBytes,
857
+ });
894
858
  }
895
859
 
896
860
  static #getRscMaxRouteModules(): number | null {
897
- return RscWorker.#parsePositiveIntEnv("AKAN_RSC_WORKER_MAX_ROUTE_MODULES");
861
+ return MemoryLimit.parsePositiveIntEnv("AKAN_RSC_WORKER_MAX_ROUTE_MODULES");
898
862
  }
899
863
  }
@@ -18,6 +18,14 @@ export interface BuildRouteResultPayload {
18
18
  routeId?: string;
19
19
  generation?: number;
20
20
  }
21
+ /**
22
+ * Marks a frontend payload that re-announces a freshly booted artifact rather than reporting an edit.
23
+ * A recycled builder rebuilds every artifact from scratch, but a running backend read
24
+ * `base-artifact.json` once at boot and never re-reads it, so the new state has to be pushed. The
25
+ * host drops such a payload when the hashed output did not actually move, which keeps a clean
26
+ * recycle invisible to connected browsers.
27
+ */
28
+ export type BuilderStateReason = "builder-recycle";
21
29
  export interface CssPayload {
22
30
  cssAssets: Record<string, {
23
31
  cssUrl: string;
@@ -26,6 +34,7 @@ export interface CssPayload {
26
34
  cssBase64ByUrl: Record<string, string>;
27
35
  generation?: number;
28
36
  changedFiles?: string[];
37
+ reason?: BuilderStateReason;
29
38
  }
30
39
  export type DevChangeRole = "server" | "client" | "shared" | "barrel" | "config" | "css";
31
40
  export type DevChangeAction = "restart-backend" | "restart-builder" | "rebuild-client" | "rebuild-css" | "sync-generated" | "restart-dev-host" | "report-error";
@@ -85,11 +94,35 @@ export type BuilderCsrRes = {
85
94
  ok: false;
86
95
  error: string;
87
96
  };
97
+ /**
98
+ * Asks the builder to finish its queued work and exit, which is the only way bundler memory is
99
+ * returned to the OS (see `BuilderMetrics`). The host's existing restart path brings up a
100
+ * replacement, so a graceful drain — rather than a kill — keeps a rebuild in flight from being
101
+ * truncated.
102
+ */
103
+ export type BuilderControl = {
104
+ type: "builder-shutdown";
105
+ reason: string;
106
+ };
88
107
  export interface PagesBundlePayload {
89
108
  bundlePath: string;
90
109
  buildId: number;
91
110
  generation?: number;
92
111
  changedFiles?: string[];
112
+ reason?: BuilderStateReason;
113
+ }
114
+ /**
115
+ * `Bun.build` retains native bundler arenas the process never returns — `Bun.gc(true)` reclaims
116
+ * nothing and the JS heap stays flat while RSS climbs — so the builder's memory only comes back when
117
+ * it exits. It reports its own RSS here whenever its queues drain, and the dev host recycles it past
118
+ * a ceiling to turn unbounded growth into a bounded sawtooth.
119
+ */
120
+ export interface BuilderMetrics {
121
+ rssBytes: number;
122
+ /** The builder's newest generation; 0 until it has processed a watch batch since spawning. */
123
+ generation: number;
124
+ /** Work items completed since this builder spawned, so a host can require real work before recycling. */
125
+ workCount: number;
93
126
  }
94
127
  export type BuilderEvent = {
95
128
  type: "builder-ready";
@@ -112,5 +145,8 @@ export type BuilderEvent = {
112
145
  } | {
113
146
  type: "build-status";
114
147
  data: DevBuildStatus;
148
+ } | {
149
+ type: "builder-metrics";
150
+ data: BuilderMetrics;
115
151
  };
116
- export type BuilderMessage = BuilderReq | BuilderRes | BuilderCsrReq | BuilderCsrRes | BuilderEvent;
152
+ export type BuilderMessage = BuilderReq | BuilderRes | BuilderCsrReq | BuilderCsrRes | BuilderControl | BuilderEvent;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Where a process's RSS ceiling comes from, shared by every host that recycles a child to bound it.
3
+ *
4
+ * A budget can be declared explicitly, discovered from the container, or absent altogether, so each
5
+ * host asks for a fraction of whatever limit it can find and supplies its own fallback for the last
6
+ * case. Kept as its own module rather than living on a host class because both `akanjs/server` and
7
+ * the dev host in `@akanjs/devkit` need it, and neither should import the other's stack to get it.
8
+ */
9
+ export declare class MemoryLimit {
10
+ #private;
11
+ static parsePositiveIntEnv(name: string): number | null;
12
+ /** Reads a byte count with an optional unit suffix, e.g. `512mb`, `2GiB`, `1073741824`. */
13
+ static parseBytesEnv(name: string): number | null;
14
+ static readCgroupBytes(): number | null;
15
+ /**
16
+ * Resolves a ceiling from, in order: an explicit MiB env, an explicit byte env, a fraction of the
17
+ * container's memory limit, and finally the caller's fallback (`null` to leave the process
18
+ * unbounded).
19
+ */
20
+ static resolveMaxRssBytes({ megabytesEnv, bytesEnv, limitFraction, fallbackBytes, }: {
21
+ megabytesEnv: string;
22
+ bytesEnv: string;
23
+ limitFraction: number;
24
+ fallbackBytes: number | null;
25
+ }): number | null;
26
+ }
@@ -1,5 +1,5 @@
1
- import type { CSSProperties } from "react";
2
1
  import type { PageState } from "akanjs/client";
2
+ import type { CSSProperties } from "react";
3
3
  export type AkanFrameCssVarName = "--akan-top-safe-area" | "--akan-bottom-safe-area" | "--akan-top-inset" | "--akan-bottom-inset" | "--akan-page-padding-top" | "--akan-page-padding-bottom";
4
4
  export type AkanFrameCssVars = CSSProperties & Record<AkanFrameCssVarName, string>;
5
5
  export declare function getFrameCssVars(pageState: PageState): AkanFrameCssVars;
@@ -1,4 +1,4 @@
1
- import { type FrameLayoutState, type FramePlatformProfile, type FrameSnapshot, type FrameSlotRegistration, type KeyboardFrameState, type Location, type NavigationIntent, type PageState, type PathRoute, type TransitionPlan, type TransitionType } from "akanjs/client";
1
+ import { type FrameLayoutState, type FramePlatformProfile, type FrameSlotRegistration, type FrameSnapshot, type KeyboardFrameState, type Location, type NavigationIntent, type PageState, type PathRoute, type TransitionPlan, type TransitionType } from "akanjs/client";
2
2
  export type FrameSlotMap = Record<string, Record<string, FrameSlotRegistration>>;
3
3
  export type FrameSlotBucket = "active" | "pending";
4
4
  export type FrameSlotMapByBucket = Record<FrameSlotBucket, FrameSlotMap>;
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { getEnv } from "akanjs/base";
3
- import { clsx, debugFrame, DEFAULT_TOP_INSET, usePathCtx } from "akanjs/client";
3
+ import { clsx, DEFAULT_TOP_INSET, debugFrame, usePathCtx } from "akanjs/client";
4
4
  import { type ReactNode, useLayoutEffect } from "react";
5
5
 
6
6
  import { Portal } from "../Portal";
@@ -22,18 +22,15 @@ export const TopInset = ({ className, children, estimatedHeight = DEFAULT_TOP_IN
22
22
  debugFrame("topInset.mount", { path, estimatedHeight });
23
23
  return () => debugFrame("topInset.unmount", { path, estimatedHeight });
24
24
  }, [path, estimatedHeight]);
25
- useLayoutEffect(
26
- () => {
27
- if (!path) return;
28
- return registerFrameSlot({
29
- type: "topInset",
30
- scope: "page",
31
- source: "topInset",
32
- estimatedHeight,
33
- });
34
- },
35
- [registerFrameSlot, estimatedHeight, path],
36
- );
25
+ useLayoutEffect(() => {
26
+ if (!path) return;
27
+ return registerFrameSlot({
28
+ type: "topInset",
29
+ scope: "page",
30
+ source: "topInset",
31
+ estimatedHeight,
32
+ });
33
+ }, [registerFrameSlot, estimatedHeight, path]);
37
34
 
38
35
  return (
39
36
  <Portal id={`topInsetContent${suffix}`}>
@@ -1,5 +1,5 @@
1
- import type { ReactNode } from "react";
2
1
  import { getRequestStore } from "akanjs/fetch";
2
+ import type { ReactNode } from "react";
3
3
 
4
4
  interface ServerPortalStore {
5
5
  capture: (id: string, children: ReactNode) => void;
package/ui/System/CSR.tsx CHANGED
@@ -3,8 +3,8 @@
3
3
  import { getEnv } from "akanjs/base";
4
4
  import {
5
5
  clsx,
6
- debugFrame,
7
6
  Device,
7
+ debugFrame,
8
8
  getPathInfo,
9
9
  type PathRoute,
10
10
  type ReactFont,
@@ -16,7 +16,7 @@ import {
16
16
  import { st } from "akanjs/store";
17
17
  import { animated } from "akanjs/ui";
18
18
  import { useFetch } from "akanjs/webkit";
19
- import { createElement, memo, type ComponentProps, type ReactNode, type RefObject, useEffect, useRef } from "react";
19
+ import { type ComponentProps, createElement, memo, type ReactNode, type RefObject, useEffect, useRef } from "react";
20
20
  import { createPortal } from "react-dom";
21
21
 
22
22
  import { FontFace } from "../FontFace";
@@ -369,12 +369,14 @@ const CSRFrameSlotTargets = ({ slot }: { slot: FrameSlotTarget }) => {
369
369
  (pageType === "prev" && (slot === "bottomInset" || slot === "keyboardInset")),
370
370
  "pointer-events-none absolute opacity-0": pageType === "pending",
371
371
  })}
372
- style={{
373
- ...getFrameCssVars(pathRoute.pageState),
374
- ...(style ?? {}),
375
- zIndex: pageType === "pending" ? -1 : zIndex,
376
- ...(pageType === "pending" ? { opacity: 0 } : {}),
377
- } as ComponentProps<typeof animated.div>["style"]}
372
+ style={
373
+ {
374
+ ...getFrameCssVars(pathRoute.pageState),
375
+ ...(style ?? {}),
376
+ zIndex: pageType === "pending" ? -1 : zIndex,
377
+ ...(pageType === "pending" ? { opacity: 0 } : {}),
378
+ } as ComponentProps<typeof animated.div>["style"]
379
+ }
378
380
  />
379
381
  );
380
382
  })}
@@ -447,14 +449,7 @@ const CSRPageContainer = ({ pathRoute, prefix, layoutStyle }: CSRPageContainerPr
447
449
  if (!pageType) return null;
448
450
  const pageContainers = document.getElementById("pageContainers");
449
451
  if (!pageContainers) return null;
450
- const {
451
- location,
452
- page,
453
- pageContentRef,
454
- pageClassName,
455
- pageBind,
456
- zIndex,
457
- } =
452
+ const { location, page, pageContentRef, pageClassName, pageBind, zIndex } =
458
453
  pageType === "current"
459
454
  ? {
460
455
  location: currentLocation,
@@ -473,23 +468,23 @@ const CSRPageContainer = ({ pathRoute, prefix, layoutStyle }: CSRPageContainerPr
473
468
  pageBind: () => ({}),
474
469
  zIndex: history.current.idxMap.get(prevLocation?.pathname ?? "") ?? 0,
475
470
  }
476
- : pageType === "pending"
477
- ? {
478
- location: pendingLocation,
479
- page: null,
480
- pageContentRef: null,
481
- pageClassName: "",
482
- pageBind: () => ({}),
483
- zIndex: history.current.idx + 1,
484
- }
485
- : {
486
- location: history.current.cachedLocationMap.get(pathRoute.path),
487
- page: null,
488
- pageContentRef: null,
489
- pageClassName: "",
490
- pageBind: () => ({}),
491
- zIndex: 0,
492
- };
471
+ : pageType === "pending"
472
+ ? {
473
+ location: pendingLocation,
474
+ page: null,
475
+ pageContentRef: null,
476
+ pageClassName: "",
477
+ pageBind: () => ({}),
478
+ zIndex: history.current.idx + 1,
479
+ }
480
+ : {
481
+ location: history.current.cachedLocationMap.get(pathRoute.path),
482
+ page: null,
483
+ pageContentRef: null,
484
+ pageClassName: "",
485
+ pageBind: () => ({}),
486
+ zIndex: 0,
487
+ };
493
488
  if (!location) return null;
494
489
  return (
495
490
  <>
@@ -514,7 +509,8 @@ const CSRPageContainer = ({ pathRoute, prefix, layoutStyle }: CSRPageContainerPr
514
509
  bind={pageBind}
515
510
  className={clsx("akan-page-content relative isolate w-full overflow-x-hidden bg-base-100 shadow-inner", {
516
511
  "relative isolate overflow-x-hidden bg-base-100 shadow-inner": pageType === "current",
517
- "pointer-events-none isolate h-screen w-screen overflow-hidden": pageType === "prev" || pageType === "pending",
512
+ "pointer-events-none isolate h-screen w-screen overflow-hidden":
513
+ pageType === "prev" || pageType === "pending",
518
514
  [pageClassName]: pathRoute.pageState.gesture,
519
515
  })}
520
516
  style={page?.contentStyle}
@@ -10,8 +10,8 @@ import {
10
10
  getPathInfo,
11
11
  initAuth,
12
12
  type Location,
13
- type PageState,
14
13
  navigateRsc,
14
+ type PageState,
15
15
  type PathRoute,
16
16
  pathContext,
17
17
  router,
@@ -36,11 +36,10 @@ import {
36
36
  useRef,
37
37
  useState,
38
38
  } from "react";
39
-
39
+ import { getFrameCssVars } from "./frameCssVars";
40
40
  import { Gtag } from "./Gtag";
41
41
  import { Messages } from "./Messages";
42
42
  import { Reconnect } from "./Reconnect";
43
- import { getFrameCssVars } from "./frameCssVars";
44
43
 
45
44
  declare global {
46
45
  var __AKAN_GET_SYNC_ROUTE_HREF__: ((href: string) => string) | undefined;
@@ -415,8 +414,8 @@ export const ClientSsrBridge = ({ lang, prefix = "", initialPageState }: ClientS
415
414
  }, 1000);
416
415
  };
417
416
  const handleSyncNavigation = (event: Event) => {
418
- const { href, kind = "push" } = (event as CustomEvent<{ href?: string; kind?: "push" | "replace" | "back" | "pop" }>)
419
- .detail ?? {};
417
+ const { href, kind = "push" } =
418
+ (event as CustomEvent<{ href?: string; kind?: "push" | "replace" | "back" | "pop" }>).detail ?? {};
420
419
  if (!href) return;
421
420
  const target = new URL(href, window.location.origin);
422
421
  const targetHref = `${target.pathname}${target.search}${target.hash}`;
package/ui/System/SSR.tsx CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  type WebAppManifest,
12
12
  } from "akanjs/client";
13
13
  import { getRequestFrameState, setRequestTheme } from "akanjs/fetch";
14
- import { Children, Fragment, type CSSProperties, type ReactNode, Suspense } from "react";
14
+ import { Children, type CSSProperties, Fragment, type ReactNode, Suspense } from "react";
15
15
  import { FontCss } from "../fontCss";
16
16
  import { Load } from "../Load";
17
17
  import { createServerPortalStore, ServerPortalOutlet, setActiveServerPortalStore } from "../ServerPortal";
@@ -187,81 +187,81 @@ const SSRWrapper = ({
187
187
  initialHash={hash}
188
188
  initialPageState={pageState}
189
189
  >
190
- <div
191
- key="top-safe-area"
192
- id="topSafeArea"
193
- className={clsx("fixed inset-x-0 top-0 bg-base-100")}
194
- style={topSafeAreaStyle}
195
- />
196
- <div key="page-containers" id="pageContainers" className={clsx("isolate")}>
197
- <div id="pageContainer">
198
- <div
199
- id="pageContent"
200
- style={pageContentStyle}
201
- className={clsx("relative isolate", {
202
- "w-full": layoutStyle === "web",
203
- "left-1/2 h-screen w-[600px] -translate-x-1/2": layoutStyle === "mobile",
204
- })}
205
- >
206
- {Children.toArray(children)}
207
- </div>
190
+ <div
191
+ key="top-safe-area"
192
+ id="topSafeArea"
193
+ className={clsx("fixed inset-x-0 top-0 bg-base-100")}
194
+ style={topSafeAreaStyle}
195
+ />
196
+ <div key="page-containers" id="pageContainers" className={clsx("isolate")}>
197
+ <div id="pageContainer">
198
+ <div
199
+ id="pageContent"
200
+ style={pageContentStyle}
201
+ className={clsx("relative isolate", {
202
+ "w-full": layoutStyle === "web",
203
+ "left-1/2 h-screen w-[600px] -translate-x-1/2": layoutStyle === "mobile",
204
+ })}
205
+ >
206
+ {Children.toArray(children)}
208
207
  </div>
209
208
  </div>
210
- <div
211
- key="top-inset"
212
- id="topInsetContainer"
213
- className={clsx("fixed inset-x-0 top-0 isolate bg-base-100", {
214
- "left-1/2 w-[600px] -translate-x-1/2": layoutStyle === "mobile",
215
- "w-full": layoutStyle === "web",
216
- })}
217
- style={topInsetStyle}
218
- >
219
- <div id="topInsetContent" className={clsx("relative isolate size-full")}>
220
- <ServerPortalOutlet id="topInsetContent" />
221
- </div>
209
+ </div>
210
+ <div
211
+ key="top-inset"
212
+ id="topInsetContainer"
213
+ className={clsx("fixed inset-x-0 top-0 isolate bg-base-100", {
214
+ "left-1/2 w-[600px] -translate-x-1/2": layoutStyle === "mobile",
215
+ "w-full": layoutStyle === "web",
216
+ })}
217
+ style={topInsetStyle}
218
+ >
219
+ <div id="topInsetContent" className={clsx("relative isolate size-full")}>
220
+ <ServerPortalOutlet id="topInsetContent" />
222
221
  </div>
223
- <div
224
- key="top-left-action"
225
- id="topLeftActionContainer"
226
- className="absolute top-0 left-0 isolate flex aspect-1 items-center justify-center"
227
- style={topInsetStyle}
228
- >
229
- <div id="topLeftActionContent" className="isolate flex size-full items-center justify-center">
230
- <ServerPortalOutlet id="topLeftActionContent" />
231
- </div>
222
+ </div>
223
+ <div
224
+ key="top-left-action"
225
+ id="topLeftActionContainer"
226
+ className="absolute top-0 left-0 isolate flex aspect-1 items-center justify-center"
227
+ style={topInsetStyle}
228
+ >
229
+ <div id="topLeftActionContent" className="isolate flex size-full items-center justify-center">
230
+ <ServerPortalOutlet id="topLeftActionContent" />
232
231
  </div>
233
- <div
234
- key="bottom-inset"
235
- id="bottomInsetContainer"
236
- className={clsx("pointer-events-none fixed inset-x-0 bottom-0 isolate overflow-hidden", {
237
- "left-1/2 w-[600px] -translate-x-1/2": layoutStyle === "mobile",
238
- "w-full": layoutStyle === "web",
239
- })}
240
- style={bottomInsetStyle}
241
- >
242
- <div id="bottomInsetContent" className="pointer-events-none isolate size-full">
243
- <ServerPortalOutlet id="bottomInsetContent" />
244
- </div>
232
+ </div>
233
+ <div
234
+ key="bottom-inset"
235
+ id="bottomInsetContainer"
236
+ className={clsx("pointer-events-none fixed inset-x-0 bottom-0 isolate overflow-hidden", {
237
+ "left-1/2 w-[600px] -translate-x-1/2": layoutStyle === "mobile",
238
+ "w-full": layoutStyle === "web",
239
+ })}
240
+ style={bottomInsetStyle}
241
+ >
242
+ <div id="bottomInsetContent" className="pointer-events-none isolate size-full">
243
+ <ServerPortalOutlet id="bottomInsetContent" />
245
244
  </div>
246
- <div
247
- key="keyboard-inset"
248
- id="keyboardInsetContainer"
249
- className={clsx("pointer-events-none fixed inset-x-0 bottom-0 isolate overflow-hidden", {
250
- "left-1/2 w-[600px] -translate-x-1/2": layoutStyle === "mobile",
251
- "w-full": layoutStyle === "web",
252
- })}
253
- style={bottomInsetStyle}
254
- >
255
- <div id="keyboardInsetContent" className="pointer-events-none isolate size-full">
256
- <ServerPortalOutlet id="keyboardInsetContent" />
257
- </div>
245
+ </div>
246
+ <div
247
+ key="keyboard-inset"
248
+ id="keyboardInsetContainer"
249
+ className={clsx("pointer-events-none fixed inset-x-0 bottom-0 isolate overflow-hidden", {
250
+ "left-1/2 w-[600px] -translate-x-1/2": layoutStyle === "mobile",
251
+ "w-full": layoutStyle === "web",
252
+ })}
253
+ style={bottomInsetStyle}
254
+ >
255
+ <div id="keyboardInsetContent" className="pointer-events-none isolate size-full">
256
+ <ServerPortalOutlet id="keyboardInsetContent" />
258
257
  </div>
259
- <div
260
- key="bottom-safe-area"
261
- id="bottomSafeArea"
262
- className="fixed inset-x-0 bg-base-100"
263
- style={bottomSafeAreaStyle}
264
- />
258
+ </div>
259
+ <div
260
+ key="bottom-safe-area"
261
+ id="bottomSafeArea"
262
+ className="fixed inset-x-0 bg-base-100"
263
+ style={bottomSafeAreaStyle}
264
+ />
265
265
  </ClientPathWrapper>
266
266
  </div>
267
267
  </>
@@ -1,5 +1,5 @@
1
- import type { CSSProperties } from "react";
2
1
  import type { PageState } from "akanjs/client";
2
+ import type { CSSProperties } from "react";
3
3
 
4
4
  export type AkanFrameCssVarName =
5
5
  | "--akan-top-safe-area"
@@ -4,8 +4,9 @@ import { useDrag } from "@use-gesture/react";
4
4
  import {
5
5
  type CsrContextType,
6
6
  type CsrTransitionStyles,
7
- debugFrame,
7
+ router as clientRouter,
8
8
  Device,
9
+ debugFrame,
9
10
  defaultPageState,
10
11
  getPathInfo,
11
12
  type LocationState,
@@ -17,12 +18,11 @@ import {
17
18
  type RouteOptions,
18
19
  type RouterInstance,
19
20
  type RouteState,
20
- router as clientRouter,
21
21
  type TransitionType,
22
22
  type UseCsrTransition,
23
23
  } from "akanjs/client";
24
- import { parseAkanI18nEnv, parseBasePaths } from "akanjs/common";
25
24
  import { loadCapacitorApp } from "akanjs/client/capacitor";
25
+ import { parseAkanI18nEnv, parseBasePaths } from "akanjs/common";
26
26
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
27
27
  import {
28
28
  createFrameSnapshot,
@@ -30,13 +30,13 @@ import {
30
30
  FRAME_Z_INDEX,
31
31
  getFramePlatformProfile,
32
32
  getFrameSlotsForSnapshot,
33
- resolveKeyboardAccessoryHeight,
34
- resolveKeyboardLayout,
35
33
  hasKeyboardStickySlot,
36
34
  isPendingFrameReady,
37
- prepareForFrameTransition,
38
35
  PENDING_FRAME_READY_TIMEOUT_MS,
36
+ prepareForFrameTransition,
39
37
  resolveFramePageStateMap,
38
+ resolveKeyboardAccessoryHeight,
39
+ resolveKeyboardLayout,
40
40
  resolveLocationWithFrameState,
41
41
  resolvePathRoutesWithFrameState,
42
42
  useFrameRuntimeResync,
@@ -72,7 +72,12 @@ const getVelocityAwareDuration = (distance: number, velocity: number, fallback:
72
72
  return clamp(Math.round(Math.abs(distance) / absVelocity), STACK_SETTLE_MIN_DURATION, STACK_SETTLE_MAX_DURATION);
73
73
  };
74
74
 
75
- const getSyncRouteHref = (location: { pathname: string; search: string; hash: string; params?: { [key: string]: string } }) => {
75
+ const getSyncRouteHref = (location: {
76
+ pathname: string;
77
+ search: string;
78
+ hash: string;
79
+ params?: { [key: string]: string };
80
+ }) => {
76
81
  const segments = location.pathname.split("/").filter(Boolean);
77
82
  const lang = location.params?.lang ?? parseAkanI18nEnv().locales.find((locale) => locale === segments[0]) ?? "";
78
83
  const configuredBasePaths = new Set(parseBasePaths(process.env.AKAN_PUBLIC_BASE_PATHS));
@@ -91,7 +96,9 @@ const getKeyboardAwarePageHeight = ({ frameLayout }: RouteState) => frameLayout.
91
96
  const getKeyboardAwareBottomPadding = ({ frameLayout }: RouteState, pageState: PathRoute["pageState"]) =>
92
97
  Math.max(
93
98
  pageState.bottomSafeArea,
94
- pageState.bottomInset + pageState.bottomSafeArea - (frameLayout.keyboard.sticky ? frameLayout.keyboardAccessory.height : 0),
99
+ pageState.bottomInset +
100
+ pageState.bottomSafeArea -
101
+ (frameLayout.keyboard.sticky ? frameLayout.keyboardAccessory.height : 0),
95
102
  );
96
103
 
97
104
  const useNoneTrans = (routeState: RouteState): UseCsrTransition => {
@@ -822,7 +829,14 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
822
829
  ],
823
830
  basePageStateMap: basePageStateMap.current,
824
831
  }),
825
- [pathRoutes, frameSlots, pendingFrameSlots, pendingLocation?.pathRoute.path, location.pathRoute.path, prevLocation?.pathRoute.path],
832
+ [
833
+ pathRoutes,
834
+ frameSlots,
835
+ pendingFrameSlots,
836
+ pendingLocation?.pathRoute.path,
837
+ location.pathRoute.path,
838
+ prevLocation?.pathRoute.path,
839
+ ],
826
840
  );
827
841
  useEffect(() => {
828
842
  if (!transitionPageStateSnapshot) pageStateByPathRef.current = pageStateByPath;
@@ -991,18 +1005,21 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
991
1005
  if (kind === "push") window.history.pushState({}, "", href);
992
1006
  else window.history.replaceState({}, "", href);
993
1007
  debugFrame("navigation.commit", { id: intent.id, kind, to: href });
994
- window.setTimeout(() => {
995
- setTransitionPageStateSnapshot(null);
996
- setLocationState((current) => ({
997
- ...current,
998
- pendingLocation: null,
999
- navigationIntent: null,
1000
- phase: "idle",
1001
- }));
1002
- navigationLocked.current = false;
1003
- clearPendingSlots();
1004
- debugFrame("transition.actionEnd", { id: plan.id, phase: "idle" });
1005
- }, Math.max(360, plan.duration));
1008
+ window.setTimeout(
1009
+ () => {
1010
+ setTransitionPageStateSnapshot(null);
1011
+ setLocationState((current) => ({
1012
+ ...current,
1013
+ pendingLocation: null,
1014
+ navigationIntent: null,
1015
+ phase: "idle",
1016
+ }));
1017
+ navigationLocked.current = false;
1018
+ clearPendingSlots();
1019
+ debugFrame("transition.actionEnd", { id: plan.id, phase: "idle" });
1020
+ },
1021
+ Math.max(360, plan.duration),
1022
+ );
1006
1023
  };
1007
1024
 
1008
1025
  const waitUntilReady = () => {
@@ -1170,8 +1187,17 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
1170
1187
  if (lastBroadcastSyncHref.current === syncHref) return;
1171
1188
  lastBroadcastSyncHref.current = syncHref;
1172
1189
  if (applyingSyncNavigation.current || globalThis.__AKAN_DEV_SYNC_NAVIGATION_APPLYING__) return;
1173
- broadcastSyncNavigation(history.current.type === "back" ? "back" : history.current.type === "forward" ? "push" : "pop", syncHref);
1174
- }, [resolvedLocation.pathRoute.path, resolvedLocation.search, resolvedLocation.hash, broadcastSyncNavigation, history]);
1190
+ broadcastSyncNavigation(
1191
+ history.current.type === "back" ? "back" : history.current.type === "forward" ? "push" : "pop",
1192
+ syncHref,
1193
+ );
1194
+ }, [
1195
+ resolvedLocation.pathRoute.path,
1196
+ resolvedLocation.search,
1197
+ resolvedLocation.hash,
1198
+ broadcastSyncNavigation,
1199
+ history,
1200
+ ]);
1175
1201
  useEffect(() => {
1176
1202
  const resetSyncNavigation = () => {
1177
1203
  window.setTimeout(() => {
@@ -1180,15 +1206,16 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
1180
1206
  }, 1000);
1181
1207
  };
1182
1208
  const handleSyncNavigation = (event: Event) => {
1183
- const { href, kind = "push" } = (event as CustomEvent<{ href?: string; kind?: "push" | "replace" | "back" | "pop" }>)
1184
- .detail ?? {};
1209
+ const { href, kind = "push" } =
1210
+ (event as CustomEvent<{ href?: string; kind?: "push" | "replace" | "back" | "pop" }>).detail ?? {};
1185
1211
  if (!href) return;
1186
1212
  const target = new URL(href, window.location.origin);
1187
1213
  const targetHref = `${target.pathname}${target.search}${target.hash}`;
1188
1214
  if (targetHref === getSyncRouteHref(getCurrentLocation())) return;
1189
1215
  applyingSyncNavigation.current = true;
1190
1216
  globalThis.__AKAN_DEV_SYNC_NAVIGATION_APPLYING__ = true;
1191
- if (kind === "replace" || kind === "back" || kind === "pop") clientRouter.replace(targetHref, { scrollToTop: false });
1217
+ if (kind === "replace" || kind === "back" || kind === "pop")
1218
+ clientRouter.replace(targetHref, { scrollToTop: false });
1192
1219
  else clientRouter.push(targetHref, { scrollToTop: false });
1193
1220
  resetSyncNavigation();
1194
1221
  };
@@ -1281,7 +1308,11 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
1281
1308
  const now = Date.now();
1282
1309
  const lastHandled = handledDeepLinkRef.current;
1283
1310
  const shouldResetStack = resetStack || (!lastHandled && now - mountedAt < 5000);
1284
- if (lastHandled?.href === href && now - lastHandled.handledAt < 1000 && (!shouldResetStack || lastHandled.resetStack))
1311
+ if (
1312
+ lastHandled?.href === href &&
1313
+ now - lastHandled.handledAt < 1000 &&
1314
+ (!shouldResetStack || lastHandled.resetStack)
1315
+ )
1285
1316
  return;
1286
1317
  handledDeepLinkRef.current = { href, handledAt: now, resetStack: shouldResetStack };
1287
1318
  debugFrame("native.deepLink", {
@@ -1,12 +1,12 @@
1
1
  "use client";
2
2
  import {
3
+ Device,
3
4
  debugFrame,
4
5
  defaultPageState,
5
- Device,
6
6
  type FrameLayoutState,
7
7
  type FramePlatformProfile,
8
- type FrameSnapshot,
9
8
  type FrameSlotRegistration,
9
+ type FrameSnapshot,
10
10
  type KeyboardAccessoryFrameState,
11
11
  type KeyboardFrameState,
12
12
  type Location,
@@ -119,34 +119,37 @@ export function useFrameSlots() {
119
119
  });
120
120
  const frameSlotId = useRef(0);
121
121
 
122
- const registerFrameSlot = useCallback((path: string, slot: FrameSlotRegistration, bucket: FrameSlotBucket = "active") => {
123
- const id = `${slot.source ?? slot.type}-${frameSlotId.current++}`;
124
- debugFrame("frameSlot.register", { path, id, bucket, slot });
125
- setFrameSlotsByBucket((prev) => ({
126
- ...prev,
127
- [bucket]: {
128
- ...prev[bucket],
129
- [path]: {
130
- ...(prev[bucket][path] ?? {}),
131
- [id]: slot,
122
+ const registerFrameSlot = useCallback(
123
+ (path: string, slot: FrameSlotRegistration, bucket: FrameSlotBucket = "active") => {
124
+ const id = `${slot.source ?? slot.type}-${frameSlotId.current++}`;
125
+ debugFrame("frameSlot.register", { path, id, bucket, slot });
126
+ setFrameSlotsByBucket((prev) => ({
127
+ ...prev,
128
+ [bucket]: {
129
+ ...prev[bucket],
130
+ [path]: {
131
+ ...(prev[bucket][path] ?? {}),
132
+ [id]: slot,
133
+ },
132
134
  },
133
- },
134
- }));
135
- return () => {
136
- debugFrame("frameSlot.unregister", { path, id, bucket, type: slot.type, source: slot.source, role: slot.role });
137
- setFrameSlotsByBucket((prev) => {
138
- const pathSlots = prev[bucket][path];
139
- if (!pathSlots?.[id]) return prev;
140
- const nextPathSlots = { ...pathSlots };
141
- delete nextPathSlots[id];
142
- const nextBucket = { ...prev[bucket] };
143
- if (Object.keys(nextPathSlots).length > 0) nextBucket[path] = nextPathSlots;
144
- else delete nextBucket[path];
145
- const next = { ...prev, [bucket]: nextBucket };
146
- return next;
147
- });
148
- };
149
- }, []);
135
+ }));
136
+ return () => {
137
+ debugFrame("frameSlot.unregister", { path, id, bucket, type: slot.type, source: slot.source, role: slot.role });
138
+ setFrameSlotsByBucket((prev) => {
139
+ const pathSlots = prev[bucket][path];
140
+ if (!pathSlots?.[id]) return prev;
141
+ const nextPathSlots = { ...pathSlots };
142
+ delete nextPathSlots[id];
143
+ const nextBucket = { ...prev[bucket] };
144
+ if (Object.keys(nextPathSlots).length > 0) nextBucket[path] = nextPathSlots;
145
+ else delete nextBucket[path];
146
+ const next = { ...prev, [bucket]: nextBucket };
147
+ return next;
148
+ });
149
+ };
150
+ },
151
+ [],
152
+ );
150
153
 
151
154
  const promotePendingSlots = useCallback(() => {
152
155
  setFrameSlotsByBucket((prev) => ({
@@ -188,9 +191,7 @@ export function getFramePlatformProfile(): FramePlatformProfile {
188
191
  (window.matchMedia?.("(display-mode: standalone)")?.matches ||
189
192
  (navigator as Navigator & { standalone?: boolean }).standalone === true);
190
193
  const hasCssSafeArea =
191
- typeof document !== "undefined" &&
192
- typeof CSS !== "undefined" &&
193
- CSS.supports("top: env(safe-area-inset-top)");
194
+ typeof document !== "undefined" && typeof CSS !== "undefined" && CSS.supports("top: env(safe-area-inset-top)");
194
195
  return isStandalone || hasCssSafeArea ? "mobileWeb" : "web";
195
196
  }
196
197
 
@@ -273,10 +274,12 @@ export function resolveKeyboardFrame({
273
274
  sticky: boolean;
274
275
  freeze?: boolean;
275
276
  }): KeyboardFrameState {
276
- const visualFallbackHeight = keyboardHeight <= 0 && visualViewportKeyboardHeight > 0 ? visualViewportKeyboardHeight : 0;
277
+ const visualFallbackHeight =
278
+ keyboardHeight <= 0 && visualViewportKeyboardHeight > 0 ? visualViewportKeyboardHeight : 0;
277
279
  const effectiveKeyboardHeight = keyboardHeight > 0 ? keyboardHeight : visualFallbackHeight;
278
280
  const source = keyboardHeight > 0 ? "native" : visualFallbackHeight > 0 ? "visualViewport" : "fallback";
279
- const visualCompensation = platformProfile === "ios" ? 0 : Math.min(visualViewportKeyboardHeight, effectiveKeyboardHeight);
281
+ const visualCompensation =
282
+ platformProfile === "ios" ? 0 : Math.min(visualViewportKeyboardHeight, effectiveKeyboardHeight);
280
283
  const offset = sticky && !freeze ? Math.max(0, effectiveKeyboardHeight - visualCompensation) : 0;
281
284
  return {
282
285
  height: effectiveKeyboardHeight,