akanjs 3.0.0-alpha.72 → 3.0.0-alpha.74

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.72",
3
+ "version": "3.0.0-alpha.74",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -18,6 +18,7 @@ import { LogHub } from "./logging/logHub";
18
18
  import { LogStreamRoute } from "./logging/logStreamRoute";
19
19
  import { RotatingLogWriter } from "./logging/rotatingLogWriter";
20
20
  import { ProcessMetricsCollector } from "./processMetricsCollector";
21
+ import { resolveStaticPath } from "./staticPath";
21
22
  import { getWebConfigFromEnv } from "./types";
22
23
 
23
24
  interface ChildState {
@@ -669,7 +670,7 @@ export class AkanApp {
669
670
  if (!this.#web.ssr) return null;
670
671
  const clientPrefix = "/_akan/client/";
671
672
  if (url.pathname.startsWith(clientPrefix)) {
672
- const filePath = this.#safeResolve(
673
+ const filePath = resolveStaticPath(
673
674
  path.join(this.#artifactDir, "client"),
674
675
  url.pathname.slice(clientPrefix.length),
675
676
  );
@@ -684,7 +685,7 @@ export class AkanApp {
684
685
 
685
686
  for (const prefix of ["/_akan/styles/", "/_akan/fonts/"]) {
686
687
  if (!url.pathname.startsWith(prefix)) continue;
687
- const filePath = this.#safeResolve(this.#artifactDir, url.pathname.slice("/_akan/".length));
688
+ const filePath = resolveStaticPath(this.#artifactDir, url.pathname.slice("/_akan/".length));
688
689
  if (!filePath) return new Response("Not Found", { status: 404 });
689
690
  const file = Bun.file(filePath);
690
691
  if (!(await file.exists())) return new Response("Not Found", { status: 404 });
@@ -831,6 +832,13 @@ export class AkanApp {
831
832
  this.#scheduleChildRestart(child, child.proc, "upstream-open-failed");
832
833
  return AkanApp.#unavailableResponse(req, "Federation child upstream is unreachable; restarting");
833
834
  }
835
+ if (req.signal.aborted) return AkanApp.#clientClosedResponse();
836
+ if (AkanApp.#isUpstreamMidFlightClose(error)) {
837
+ this.logger.warn(
838
+ `Child ${child.idx}/${child.role} closed the connection mid-request (${req.method} ${new URL(req.url).pathname})`,
839
+ );
840
+ return AkanApp.#badGatewayResponse(req);
841
+ }
834
842
  throw error;
835
843
  } finally {
836
844
  child.metrics.activeRequests = Math.max(0, (child.metrics.activeRequests ?? 1) - 1);
@@ -844,6 +852,43 @@ export class AkanApp {
844
852
  return candidate.code === "FailedToOpenSocket" || String(candidate.message ?? "").includes("FailedToOpenSocket");
845
853
  }
846
854
 
855
+ /**
856
+ * The socket opened and then died under us: the child crashed, or was recycled, while its response was still
857
+ * in flight. Distinct from `FailedToOpenSocket` — that one means the child was already gone when we dialled,
858
+ * and only that one implies the socket needs a restart, which the child's own exit handler schedules anyway.
859
+ * Rethrowing this instead reaches Bun as an unhandled route error, which answers 500 and prints the raw
860
+ * `TypeError` against whatever URL happened to be in flight — usually a browser background probe, which is
861
+ * how a dead child gets misread as a bug in the probed route.
862
+ */
863
+ static #isUpstreamMidFlightClose(error: unknown): boolean {
864
+ if (!error || typeof error !== "object") return false;
865
+ const candidate = error as { code?: unknown; message?: unknown };
866
+ if (candidate.code === "ECONNRESET" || candidate.code === "ConnectionClosed") return true;
867
+ return String(candidate.message ?? "").includes("connection was closed");
868
+ }
869
+
870
+ static #clientClosedResponse(): Response {
871
+ return new Response(null, { status: 499, headers: { "cache-control": "no-store" } });
872
+ }
873
+
874
+ static #badGatewayResponse(req: Request): Response {
875
+ const detail = "The replica closed the connection before it finished answering.";
876
+ if (req.headers.get("accept")?.includes("text/html")) {
877
+ return new Response(
878
+ AkanApp.#statusPageHtml({
879
+ heading: "Backend dropped the connection",
880
+ detail,
881
+ note: "The replica is restarting — this page reloads itself as soon as it answers.",
882
+ }),
883
+ { status: 502, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } },
884
+ );
885
+ }
886
+ return new Response(detail, {
887
+ status: 502,
888
+ headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
889
+ });
890
+ }
891
+
847
892
  /**
848
893
  * A replica that is booting or restarting is normally back within a second or two, and a browser
849
894
  * handed a 503 stays on that dead page until somebody reloads by hand — so a request waits for the
@@ -1018,23 +1063,6 @@ export class AkanApp {
1018
1063
  return new Response(Bun.file(filePath).stream(), { headers });
1019
1064
  }
1020
1065
 
1021
- #safeResolve(baseDir: string, urlPath: string): string | null {
1022
- let decoded: string;
1023
- try {
1024
- decoded = decodeURIComponent(urlPath);
1025
- } catch {
1026
- return null;
1027
- }
1028
- if (decoded.includes("\0")) return null;
1029
- const normalizedBase = path.resolve(baseDir);
1030
- const rel = decoded.replace(/^[/\\]+/, "");
1031
- const resolved = path.resolve(normalizedBase, rel);
1032
- if (resolved === normalizedBase) return resolved;
1033
- const baseWithSep = normalizedBase.endsWith(path.sep) ? normalizedBase : `${normalizedBase}${path.sep}`;
1034
- if (!resolved.startsWith(baseWithSep)) return null;
1035
- return resolved;
1036
- }
1037
-
1038
1066
  #makeProxyHeaders(req: Request, childIdx: number, server?: Bun.Server<GatewayWsData>) {
1039
1067
  return makeAkanChildProxyHeaders(req, childIdx, server?.requestIP(req));
1040
1068
  }
@@ -5,6 +5,7 @@ import { Logger } from "akanjs/common";
5
5
  import { isAnimatedImage } from "./animatedImage";
6
6
  import { ImageOptimizerError } from "./imageOptimizerError";
7
7
  import { Semaphore } from "./semaphore";
8
+ import { resolveStaticPath } from "./staticPath";
8
9
  import {
9
10
  type AkanImageConfig,
10
11
  type AkanImageFormat,
@@ -260,7 +261,7 @@ export class ImageOptimizer {
260
261
 
261
262
  async #readLocalImage(href: string): Promise<ImageSource> {
262
263
  const url = new URL(href, "http://local.akan");
263
- const filePath = ImageOptimizer.#safeResolve(this.#publicDir, url.pathname);
264
+ const filePath = resolveStaticPath(this.#publicDir, url.pathname);
264
265
  if (!filePath) throw new ImageOptimizerError(400, '"url" parameter is not allowed');
265
266
  const file = Bun.file(filePath);
266
267
  if (!(await file.exists())) throw new ImageOptimizerError(404, "Image not found");
@@ -455,22 +456,6 @@ export class ImageOptimizer {
455
456
  }
456
457
  }
457
458
 
458
- static #safeResolve(baseDir: string, urlPath: string): string | null {
459
- let decoded: string;
460
- try {
461
- decoded = decodeURIComponent(urlPath);
462
- } catch {
463
- return null;
464
- }
465
- if (decoded.includes("\0")) return null;
466
- const normalizedBase = path.resolve(baseDir);
467
- const rel = decoded.replace(/^[/\\]+/, "");
468
- const resolved = path.resolve(normalizedBase, rel);
469
- if (resolved === normalizedBase) return resolved;
470
- const baseWithSep = normalizedBase.endsWith(path.sep) ? normalizedBase : normalizedBase + path.sep;
471
- return resolved.startsWith(baseWithSep) ? resolved : null;
472
- }
473
-
474
459
  static async #readResponseBuffer(res: Response, maxBytes: number): Promise<Buffer> {
475
460
  const contentLength = Number.parseInt(res.headers.get("content-length") ?? "0", 10);
476
461
  if (contentLength > maxBytes) throw new ImageOptimizerError(413, "Remote image is too large");
@@ -4,7 +4,8 @@ export class ShutdownManager {
4
4
  /**
5
5
  * Registers process-level handlers that drive `onShutdown` on:
6
6
  * - SIGTERM / SIGINT (graceful stop → exit(0) on success, exit(1) on error)
7
- * - uncaughtException / unhandledRejection (best-effort stop → exit(1))
7
+ * - uncaughtException (best-effort stop → exit(1))
8
+ * - unhandledRejection (logged at error, process kept alive; see `#isFatalUnhandledRejection`)
8
9
  *
9
10
  * Kept separate from `AkanServer` so the server class doesn't have to know about
10
11
  * Node-level process events, and so tests can opt out by not calling it.
@@ -37,6 +38,7 @@ export class ShutdownManager {
37
38
 
38
39
  process.on("unhandledRejection", async (reason) => {
39
40
  logger.error(`Unhandled rejection: ${ShutdownManager.#formatError(reason)}`);
41
+ if (!ShutdownManager.#isFatalUnhandledRejection()) return;
40
42
  try {
41
43
  await onShutdown();
42
44
  process.exit(1);
@@ -46,6 +48,19 @@ export class ShutdownManager {
46
48
  });
47
49
  }
48
50
 
51
+ /**
52
+ * A rejected promise nobody awaited is scoped to whatever built it, so it cannot leave the process in the
53
+ * unknown state an `uncaughtException` does — while exiting over one drops every other in-flight request,
54
+ * every websocket, and (under federation) restarts the replica. React's streaming SSR is the case that forced
55
+ * this: it rejects `stream.allReady` for a post-shell render failure and holds no handler of its own, so one
56
+ * page's boundary error used to kill a whole child. Set `AKAN_FATAL_UNHANDLED_REJECTION=1` to restore the
57
+ * exit for a deployment that would rather crash than serve on.
58
+ */
59
+ static #isFatalUnhandledRejection(): boolean {
60
+ const flag = process.env.AKAN_FATAL_UNHANDLED_REJECTION;
61
+ return flag === "1" || flag === "true";
62
+ }
63
+
49
64
  static #formatError(error: unknown): string {
50
65
  return error instanceof Error ? (error.stack ?? error.message) : String(error);
51
66
  }
@@ -850,6 +850,32 @@ export class RscRenderer {
850
850
  this.#send({ type: "metrics", metrics });
851
851
  }
852
852
 
853
+ /**
854
+ * An error raised after the first Flight chunk has left the worker can no longer become a status code or a
855
+ * system error page — `sendLateRedirect` is the only control the host can still act on, so the render
856
+ * control is dropped. Logging here is the only record that the request failed at all; without it a page
857
+ * whose boundary died mid-stream is indistinguishable from one that rendered.
858
+ */
859
+ #reportRenderError(error: unknown, pathname?: string): void {
860
+ const description = error instanceof Error ? (error.stack ?? error.message) : String(error);
861
+ const scope = pathname ? ` path=${pathname}` : "";
862
+ if (RscRenderer.#isExpectedRequestAbort(error)) {
863
+ this.#logger.debug(`[rsc] render aborted${scope}: ${description}`);
864
+ return;
865
+ }
866
+ this.#logger.error(`[rsc] render failed${scope}: ${description}`);
867
+ }
868
+
869
+ /** A client that navigated away, or a stream the host cancelled: expected, and not the replica's problem. */
870
+ static #isExpectedRequestAbort(error: unknown): boolean {
871
+ if (!(error instanceof Error)) return false;
872
+ return (
873
+ error.name === "AbortError" ||
874
+ error.message === "Connection closed." ||
875
+ error.message.includes("The connection was closed")
876
+ );
877
+ }
878
+
853
879
  async #renderFlightElement(
854
880
  element: ReactNode,
855
881
  clientManifest: ClientManifest,
@@ -888,6 +914,7 @@ export class RscRenderer {
888
914
  return error.digest;
889
915
  }
890
916
  controlRef.current = { type: "error", error };
917
+ this.#reportRenderError(error, options.trace?.pathname);
891
918
  return error instanceof Error ? error.message : String(error);
892
919
  },
893
920
  });
@@ -1,4 +1,5 @@
1
1
  import { Readable } from "node:stream";
2
+ import { Logger } from "akanjs/common";
2
3
  import { type AkanRequestStore, type AkanTheme, pushRequestFallback, requestStorage } from "akanjs/fetch";
3
4
  import type { ReactNode } from "react";
4
5
  import { renderToReadableStream } from "react-dom/server.browser";
@@ -575,6 +576,8 @@ export class SsrFromRscRenderer {
575
576
  SsrFromRscRenderer.#installWebpackShims();
576
577
  }
577
578
 
579
+ static readonly #logger = new Logger("SsrFromRscRenderer");
580
+
578
581
  static getChunkRegistryStats(): SsrChunkRegistryStats {
579
582
  return { ...SsrFromRscRenderer.#chunkRegistryStats };
580
583
  }
@@ -599,8 +602,14 @@ export class SsrFromRscRenderer {
599
602
  const root = await thenable;
600
603
  const stream = await renderToReadableStream(root, {
601
604
  bootstrapScriptContent: bootstrap,
605
+ onError: (error) => SsrFromRscRenderer.#reportRenderError(error, input),
602
606
  });
603
607
  if (waitForAllReady) await stream.allReady;
608
+ else {
609
+ SsrFromRscRenderer.holdPostShellErrors(stream, (error) =>
610
+ SsrFromRscRenderer.#reportRenderError(error, input, "post-shell"),
611
+ );
612
+ }
604
613
  return stream;
605
614
  };
606
615
  const requestContext = input.requestStore ?? input.request;
@@ -657,6 +666,47 @@ export class SsrFromRscRenderer {
657
666
  };
658
667
  }
659
668
 
669
+ /**
670
+ * React rejects `stream.allReady` for any fatal error raised after the shell flushed — an abort landing
671
+ * mid-flush, a `flushCompletedQueues` invariant — and it attaches a handler to that promise itself only on
672
+ * the shell-error path, because `completeShell` replaces `onShellError` with a noop. Shell-first streaming
673
+ * never reads `allReady`, so an unheld rejection reaches `process.on("unhandledRejection")`, and under
674
+ * federation that took the whole replica down over one request's render.
675
+ *
676
+ * Holding it keeps the failure scoped to the stream that raised it. The response cannot become a 5xx — the
677
+ * shell's headers and markup are already on the wire — so the client gets a truncated document and the stack
678
+ * goes to the log; every other request and socket on the replica survives.
679
+ */
680
+ static holdPostShellErrors(stream: { allReady: Promise<void> }, report: (error: unknown) => void): void {
681
+ void stream.allReady.catch(report);
682
+ }
683
+
684
+ static #reportRenderError(error: unknown, input: SsrFromRscInput, phase = "render"): void {
685
+ const description = SsrFromRscRenderer.#describeError(error);
686
+ if (SsrFromRscRenderer.#isExpectedRequestAbort(error)) {
687
+ SsrFromRscRenderer.#logger.debug(`[SSR] ${phase} aborted: ${description}`);
688
+ return;
689
+ }
690
+ const path = input.request ? new URL(input.request.url).pathname : "unknown";
691
+ SsrFromRscRenderer.#logger.error(`[SSR] ${phase} failed path=${path}: ${description}`);
692
+ }
693
+
694
+ static #describeError(error: unknown): string {
695
+ if (!(error instanceof Error)) return String(error);
696
+ return error.stack ?? error.message;
697
+ }
698
+
699
+ /** A client that navigated away, or a stream we cancelled ourselves: expected, and not the replica's problem. */
700
+ static #isExpectedRequestAbort(error: unknown): boolean {
701
+ if (!(error instanceof Error)) return false;
702
+ return (
703
+ error.name === "AbortError" ||
704
+ error.name === "AkanRedirectError" ||
705
+ error.message === "Connection closed." ||
706
+ error.message.includes("The connection was closed")
707
+ );
708
+ }
709
+
660
710
  static #suppressExpectedLateRedirectError(
661
711
  thenable: PromiseLike<ReactNode>,
662
712
  lateControl?: Promise<SsrLateRedirect | null>,
@@ -1,4 +1,3 @@
1
- import fs from "node:fs";
2
1
  import path from "node:path";
3
2
 
4
3
  /**
@@ -9,9 +8,13 @@ import path from "node:path";
9
8
  * then a NUL check (a truncating byte in a path), then `path.resolve` to collapse the traversal, then
10
9
  * containment against the base **with a separator** — without it `/public-secrets` passes a `/public` check.
11
10
  *
12
- * `path.resolve` works on the string and does not follow links, so a symlink inside the tree can still point
13
- * out of it. Callers check existence after this, so a path that resolves to nothing is handed back and 404s
14
- * there; only a link that really leads outside is refused.
11
+ * Symlinks are followed, like nginx and `serve-static` do: `akan sync` builds `public/libs/<lib>` as a link to
12
+ * `<workspaceRoot>/libs/<lib>/public`, so a link out of the tree is how a lib ships its assets, not an attack.
13
+ * Refusing those would 404 every lib asset under `akan start` and nothing else — `akan build` copies `public/`
14
+ * with `dereference: true`, so a built app has no links left to refuse. Traversal is what the string checks
15
+ * above stop, and they run before any of this touches the filesystem.
16
+ *
17
+ * Callers check existence after this, so a path that resolves to nothing is handed back and 404s there.
15
18
  */
16
19
  export const resolveStaticPath = (baseDir: string, urlPath: string): string | null => {
17
20
  let decoded: string;
@@ -27,13 +30,5 @@ export const resolveStaticPath = (baseDir: string, urlPath: string): string | nu
27
30
  if (resolved === normalizedBase) return resolved;
28
31
  const baseWithSep = normalizedBase.endsWith(path.sep) ? normalizedBase : normalizedBase + path.sep;
29
32
  if (!resolved.startsWith(baseWithSep)) return null;
30
- try {
31
-
32
- const realBase = fs.realpathSync(normalizedBase);
33
- const realBaseWithSep = realBase.endsWith(path.sep) ? realBase : realBase + path.sep;
34
- const real = fs.realpathSync(resolved);
35
- if (real !== realBase && !real.startsWith(realBaseWithSep)) return null;
36
- } catch {
37
- }
38
33
  return resolved;
39
34
  };
@@ -519,6 +519,12 @@ export class WebRouter {
519
519
  WebRouter.#deepLinkAssociationResponse(ANDROID_ASSET_LINKS_PATH, this.#artifact, {
520
520
  cacheControl: this.#prodMode ? "public, max-age=3600" : "no-store",
521
521
  }) ?? new Response("Not Found", { status: 404 }),
522
+
523
+ "/.well-known/*": () =>
524
+ new Response("Not Found", {
525
+ status: 404,
526
+ headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" },
527
+ }),
522
528
  [FIREBASE_MESSAGING_SW_PATH]: async () => {
523
529
  this.#requestStats.staticAsset += 1;
524
530
  const firebaseConfig = await WebRouter.#resolveFirebaseClientConfig();
@@ -4,7 +4,8 @@ export declare class ShutdownManager {
4
4
  /**
5
5
  * Registers process-level handlers that drive `onShutdown` on:
6
6
  * - SIGTERM / SIGINT (graceful stop → exit(0) on success, exit(1) on error)
7
- * - uncaughtException / unhandledRejection (best-effort stop → exit(1))
7
+ * - uncaughtException (best-effort stop → exit(1))
8
+ * - unhandledRejection (logged at error, process kept alive; see `#isFatalUnhandledRejection`)
8
9
  *
9
10
  * Kept separate from `AkanServer` so the server class doesn't have to know about
10
11
  * Node-level process events, and so tests can opt out by not calling it.
@@ -36,4 +36,18 @@ export declare class SsrFromRscRenderer {
36
36
  #private;
37
37
  static getChunkRegistryStats(): SsrChunkRegistryStats;
38
38
  render(input: SsrFromRscInput): Promise<ReadableStream<Uint8Array>>;
39
+ /**
40
+ * React rejects `stream.allReady` for any fatal error raised after the shell flushed — an abort landing
41
+ * mid-flush, a `flushCompletedQueues` invariant — and it attaches a handler to that promise itself only on
42
+ * the shell-error path, because `completeShell` replaces `onShellError` with a noop. Shell-first streaming
43
+ * never reads `allReady`, so an unheld rejection reaches `process.on("unhandledRejection")`, and under
44
+ * federation that took the whole replica down over one request's render.
45
+ *
46
+ * Holding it keeps the failure scoped to the stream that raised it. The response cannot become a 5xx — the
47
+ * shell's headers and markup are already on the wire — so the client gets a truncated document and the stack
48
+ * goes to the log; every other request and socket on the replica survives.
49
+ */
50
+ static holdPostShellErrors(stream: {
51
+ allReady: Promise<void>;
52
+ }, report: (error: unknown) => void): void;
39
53
  }
@@ -6,8 +6,12 @@
6
6
  * then a NUL check (a truncating byte in a path), then `path.resolve` to collapse the traversal, then
7
7
  * containment against the base **with a separator** — without it `/public-secrets` passes a `/public` check.
8
8
  *
9
- * `path.resolve` works on the string and does not follow links, so a symlink inside the tree can still point
10
- * out of it. Callers check existence after this, so a path that resolves to nothing is handed back and 404s
11
- * there; only a link that really leads outside is refused.
9
+ * Symlinks are followed, like nginx and `serve-static` do: `akan sync` builds `public/libs/<lib>` as a link to
10
+ * `<workspaceRoot>/libs/<lib>/public`, so a link out of the tree is how a lib ships its assets, not an attack.
11
+ * Refusing those would 404 every lib asset under `akan start` and nothing else — `akan build` copies `public/`
12
+ * with `dereference: true`, so a built app has no links left to refuse. Traversal is what the string checks
13
+ * above stop, and they run before any of this touches the filesystem.
14
+ *
15
+ * Callers check existence after this, so a path that resolves to nothing is handed back and 404s there.
12
16
  */
13
17
  export declare const resolveStaticPath: (baseDir: string, urlPath: string) => string | null;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * One named export per member, rather than the `Loading` namespace object itself, because the `"use client"`
3
+ * transform replaces a client module's *exports* with `registerClientReference` stubs. A namespace exported
4
+ * from here would reach a server component as one reference for the whole object, and `Loading.Skeleton` on it
5
+ * would read a property the stub does not have — `undefined`, which React reports as an invalid element type.
6
+ * The namespace is assembled in `index.ts`, which carries no directive, so each member crosses on its own.
7
+ */
8
+ export declare const LoadingArea: import("react").ComponentType<Record<string, never>>;
9
+ export declare const LoadingButton: import("react").ComponentType<import("./Button.d.ts").LoadingProps>;
10
+ export declare const LoadingInput: import("react").ComponentType<import("./Input.d.ts").LoadingProps>;
11
+ export declare const LoadingProgressBar: import("react").ComponentType<import("./ProgressBar.d.ts").ProgressBarProps>;
12
+ export declare const LoadingSkeleton: import("react").ComponentType<import("./Skeleton.d.ts").SkeletonProps>;
13
+ export declare const LoadingSpin: import("react").ComponentType<import("./Spin.d.ts").SpinProps>;
@@ -0,0 +1,15 @@
1
+ import { LoadingArea, LoadingButton, LoadingInput, LoadingProgressBar, LoadingSkeleton, LoadingSpin } from "./index_";
2
+
3
+ /**
4
+ * Loading indicators. Each member is an independent override slot (`LoadingSpin`, `LoadingSkeleton`,
5
+ * `LoadingProgressBar`, `LoadingButton`, `LoadingInput`, `LoadingArea`), resolved from the closest
6
+ * `page/**\/_overrides.tsx` in the route's ancestry, otherwise the shipped default.
7
+ */
8
+ export const Loading = {
9
+ Area: LoadingArea,
10
+ Button: LoadingButton,
11
+ Input: LoadingInput,
12
+ ProgressBar: LoadingProgressBar,
13
+ Skeleton: LoadingSkeleton,
14
+ Spin: LoadingSpin,
15
+ };
@@ -0,0 +1,22 @@
1
+ "use client";
2
+ import { createOverridable } from "../UiOverride";
3
+ import { Area } from "./Area";
4
+ import { Button } from "./Button";
5
+ import { Input } from "./Input";
6
+ import { ProgressBar } from "./ProgressBar";
7
+ import { Skeleton } from "./Skeleton";
8
+ import { Spin } from "./Spin";
9
+
10
+ /**
11
+ * One named export per member, rather than the `Loading` namespace object itself, because the `"use client"`
12
+ * transform replaces a client module's *exports* with `registerClientReference` stubs. A namespace exported
13
+ * from here would reach a server component as one reference for the whole object, and `Loading.Skeleton` on it
14
+ * would read a property the stub does not have — `undefined`, which React reports as an invalid element type.
15
+ * The namespace is assembled in `index.ts`, which carries no directive, so each member crosses on its own.
16
+ */
17
+ export const LoadingArea = createOverridable("LoadingArea", Area);
18
+ export const LoadingButton = createOverridable("LoadingButton", Button);
19
+ export const LoadingInput = createOverridable("LoadingInput", Input);
20
+ export const LoadingProgressBar = createOverridable("LoadingProgressBar", ProgressBar);
21
+ export const LoadingSkeleton = createOverridable("LoadingSkeleton", Skeleton);
22
+ export const LoadingSpin = createOverridable("LoadingSpin", Spin);
@@ -1,22 +0,0 @@
1
- "use client";
2
- import { createOverridable } from "../UiOverride";
3
- import { Area } from "./Area";
4
- import { Button } from "./Button";
5
- import { Input } from "./Input";
6
- import { ProgressBar } from "./ProgressBar";
7
- import { Skeleton } from "./Skeleton";
8
- import { Spin } from "./Spin";
9
-
10
- /**
11
- * Loading indicators. Each member is an independent override slot (`LoadingSpin`, `LoadingSkeleton`,
12
- * `LoadingProgressBar`, `LoadingButton`, `LoadingInput`, `LoadingArea`), resolved from the closest
13
- * `page/**\/_overrides.tsx` in the route's ancestry, otherwise the shipped default.
14
- */
15
- export const Loading = {
16
- Area: createOverridable("LoadingArea", Area),
17
- Button: createOverridable("LoadingButton", Button),
18
- Input: createOverridable("LoadingInput", Input),
19
- ProgressBar: createOverridable("LoadingProgressBar", ProgressBar),
20
- Skeleton: createOverridable("LoadingSkeleton", Skeleton),
21
- Spin: createOverridable("LoadingSpin", Spin),
22
- };