@ilha/router 0.11.5 → 0.11.7

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.
@@ -35,8 +35,36 @@ const ILHA_ROUTER_HTML_ATTR = "data-ilha-router-html";
35
35
  const ILHA_ROUTER_BODY_ATTR = "data-ilha-router-body";
36
36
  /** Browser-only fallback; SSR uses AsyncLocalStorage (see `withHeadStore`). */
37
37
  let _browserHeadStore = null;
38
+ /** WebContainer fallback while a serialized `withHeadStore` entry is in flight. */
39
+ let _headSyncStore = null;
40
+ /** Serialize WebContainer head entries so overlapping renders cannot share sync state. */
41
+ let _headFallbackTail = Promise.resolve(null);
38
42
  let _headAls = null;
39
43
  let _headAlsInit = null;
44
+ let _headWcOverride = null;
45
+ /** When true, activeHeadStore skips ALS so tests exercise the sync fallback. */
46
+ let _headAlsBypassForTests = false;
47
+ const headInWebcontainer = () => {
48
+ if (_headWcOverride !== null) return _headWcOverride;
49
+ if (isBrowser) return false;
50
+ try {
51
+ const versions = globalThis.process?.versions;
52
+ return Boolean(versions?.webcontainer);
53
+ } catch {
54
+ return false;
55
+ }
56
+ };
57
+ const withHeadFallbackEntry = async (fn) => {
58
+ const previous = _headFallbackTail;
59
+ const next = Promise.withResolvers();
60
+ _headFallbackTail = next.promise;
61
+ await previous;
62
+ try {
63
+ return await fn();
64
+ } finally {
65
+ next.resolve(null);
66
+ }
67
+ };
40
68
  /** ESM dynamic import — Nitro/Vite SSR workers have no `require`. */
41
69
  const getHeadAlsAsync = async () => {
42
70
  if (_headAls) return _headAls;
@@ -52,7 +80,8 @@ const getHeadAlsAsync = async () => {
52
80
  };
53
81
  const activeHeadStore = () => {
54
82
  if (isBrowser) return _browserHeadStore;
55
- return _headAls?.getStore() ?? null;
83
+ if (_headAlsBypassForTests) return _headSyncStore;
84
+ return _headAls?.getStore() ?? _headSyncStore ?? null;
56
85
  };
57
86
  const cssEscapeAttr = (value) => {
58
87
  const cssApi = globalThis.CSS;
@@ -231,7 +260,17 @@ const withHeadStore = async (store, fn) => {
231
260
  _browserHeadStore = prev;
232
261
  }
233
262
  }
234
- return await (await getHeadAlsAsync()).run(store, () => Promise.resolve(fn()));
263
+ const als = await getHeadAlsAsync();
264
+ if (!headInWebcontainer()) return await als.run(store, () => Promise.resolve(fn()));
265
+ return withHeadFallbackEntry(async () => {
266
+ const previous = _headSyncStore;
267
+ _headSyncStore = store;
268
+ try {
269
+ return await als.run(store, () => Promise.resolve(fn()));
270
+ } finally {
271
+ _headSyncStore = previous;
272
+ }
273
+ });
235
274
  };
236
275
  let _flushScheduled = false;
237
276
  let _flushTarget = null;
package/dist/head.d.ts CHANGED
@@ -42,6 +42,10 @@ export interface SerializedHead {
42
42
  export interface HeadStore {
43
43
  entries: HeadInput[];
44
44
  }
45
+ /** Test-only: force WebContainer head fallback (mirrors request-scope). */
46
+ export declare const __setHeadInWebcontainerForTests: (value: boolean | null) => void;
47
+ /** Test-only: force activeHeadStore to skip ALS (simulates WebContainer ALS loss). */
48
+ export declare const __setHeadAlsBypassForTests: (value: boolean) => void;
45
49
  export declare const cssEscapeAttr: (value: string) => string;
46
50
  export declare const escapeHeadAttr: <T>(value: T) => string;
47
51
  /** Escape text content for inline HTML (loader error messages etc.). */
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as REQUEST_ALS_KEY } from "./als-key-CVxbuM3z.js";
2
- import { a as withHeadStore, i as serializeHead, n as head, r as openBrowserHead, t as applyHeadEntriesToDocument } from "./head-BLtm0xtK.js";
2
+ import { a as withHeadStore, i as serializeHead, n as head, r as openBrowserHead, t as applyHeadEntriesToDocument } from "./head-WG_AtbdX.js";
3
3
  import { h, mount, renderToString } from "ilha";
4
4
 
5
5
  //#region src/hash.ts
@@ -1,4 +1,4 @@
1
- import { C as runWithIslandRequest, a as authorizeFrameRequest, b as setFrameAuth, c as frameScopedUrl, f as isSafeFramePath, h as parseFrameProps, n as FrameError, s as frameEnvelope, u as getFrameGuard, x as setFrameGuard, y as renderServerIslandResult } from "./ssr-Dvq_qXs4.js";
1
+ import { C as runWithIslandRequest, a as authorizeFrameRequest, b as setFrameAuth, c as frameScopedUrl, f as isSafeFramePath, h as parseFrameProps, n as FrameError, s as frameEnvelope, u as getFrameGuard, x as setFrameGuard, y as renderServerIslandResult } from "./ssr-D9q5C-OV.js";
2
2
  import { existsSync, readFileSync, statSync, watch } from "node:fs";
3
3
  import path from "node:path";
4
4
  import * as Result from "effect/Result";
@@ -892,7 +892,7 @@ const renderAuthorizedFrame = async (req, res, ctx, raw, serverOrigin, identityH
892
892
  headers: identityHeaders,
893
893
  method: "POST"
894
894
  });
895
- const result = await renderServerIslandResult(body.id ?? "", request, (r, fn) => runWithIslandRequest(r, fn), incomingProps);
895
+ const result = await renderServerIslandResult(body.id ?? "", request, (r, fn) => Promise.resolve(runWithIslandRequest(r, fn)), incomingProps);
896
896
  if (Result.isFailure(result)) {
897
897
  const err = result.failure;
898
898
  if (err.redirect) {
@@ -12,10 +12,24 @@
12
12
  * is `useContext()` from the main `@ilha/router` entry, which reads the
13
13
  * storage without importing `node:async_hooks`; this node-only module is the
14
14
  * sole place that constructs it.
15
+ *
16
+ * StackBlitz WebContainers lose AsyncLocalStorage across `async/await`. A
17
+ * module sync fallback keeps `useContext().request` readable, and unrelated
18
+ * fallback entries are serialized so concurrent renders cannot stomp each
19
+ * other. Nested calls reenter only while owned by the active entry (sync nest
20
+ * or ALS owner) so a suspended outer cannot admit unrelated concurrent work.
15
21
  */
16
22
  export { REQUEST_ALS_KEY } from "./als-key";
23
+ export { __setInWebcontainerForTests } from "./webcontainer";
17
24
  export type { IslandContext } from "./index";
18
- /** Run `fn` with `request` available to `useContext().request`. When oxidejs
25
+ /** Test-only: force getStore() to skip ALS (simulates WebContainer ALS loss). */
26
+ export declare const __setAlsBypassForTests: (value: boolean) => void;
27
+ /**
28
+ * Run `fn` with `request` available to `useContext().request`. When oxidejs
19
29
  * is loaded, its action scope is entered too, so `useRequest()` works in
20
- * island renders and streamed frames. */
21
- export declare const runWithIslandRequest: <T>(request: Request, fn: () => T) => T;
30
+ * island renders and streamed frames.
31
+ *
32
+ * On WebContainer the return is always a `Promise` (fallback lock). Elsewhere
33
+ * the return matches `fn` (sync or async).
34
+ */
35
+ export declare const runWithIslandRequest: <T>(request: Request, fn: () => T) => T | Promise<Awaited<T>>;
package/dist/rsbuild.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as ilhaPages } from "./plugin-DCdQ9yO8.js";
1
+ import { t as ilhaPages } from "./plugin-Ci-W2rMt.js";
2
2
 
3
3
  //#region src/rsbuild.ts
4
4
  /** Rsbuild plugin — use via `@ilha/router/rsbuild`. */
@@ -1,4 +1,4 @@
1
- import { t as applyHeadEntriesToDocument } from "./head-BLtm0xtK.js";
1
+ import { t as applyHeadEntriesToDocument } from "./head-WG_AtbdX.js";
2
2
  import { t as parseSnapshotAttr } from "./snapshot-XpAkSV8s.js";
3
3
 
4
4
  //#region src/server-island.ts
@@ -1,5 +1,5 @@
1
1
  import { t as REQUEST_ALS_KEY } from "./als-key-CVxbuM3z.js";
2
- import { a as withHeadStore } from "./head-BLtm0xtK.js";
2
+ import { a as withHeadStore } from "./head-WG_AtbdX.js";
3
3
  import { n as sanitizeSnapshotObject } from "./snapshot-XpAkSV8s.js";
4
4
  import { renderToString } from "ilha";
5
5
  import * as Result from "effect/Result";
@@ -8,6 +8,25 @@ import * as Data from "effect/Data";
8
8
  import * as Effect from "effect/Effect";
9
9
  import { action, brandServerAction } from "oxidejs";
10
10
 
11
+ //#region src/webcontainer.ts
12
+ /**
13
+ * StackBlitz WebContainer detection for AsyncLocalStorage fallbacks.
14
+ * Safe to import from browser-shared modules: on the client this always
15
+ * returns false (no `process.versions.webcontainer`).
16
+ */
17
+ let webcontainerOverride = null;
18
+ /** StackBlitz sets `process.versions.webcontainer` inside WebContainers. */
19
+ const inWebcontainer = () => {
20
+ if (webcontainerOverride !== null) return webcontainerOverride;
21
+ try {
22
+ const versions = globalThis.process?.versions;
23
+ return Boolean(versions?.webcontainer);
24
+ } catch {
25
+ return false;
26
+ }
27
+ };
28
+
29
+ //#endregion
11
30
  //#region src/request-scope.ts
12
31
  /**
13
32
  * Request scope for server-owned island rendering.
@@ -23,25 +42,86 @@ import { action, brandServerAction } from "oxidejs";
23
42
  * is `useContext()` from the main `@ilha/router` entry, which reads the
24
43
  * storage without importing `node:async_hooks`; this node-only module is the
25
44
  * sole place that constructs it.
45
+ *
46
+ * StackBlitz WebContainers lose AsyncLocalStorage across `async/await`. A
47
+ * module sync fallback keeps `useContext().request` readable, and unrelated
48
+ * fallback entries are serialized so concurrent renders cannot stomp each
49
+ * other. Nested calls reenter only while owned by the active entry (sync nest
50
+ * or ALS owner) so a suspended outer cannot admit unrelated concurrent work.
26
51
  */
27
52
  /** Installed by oxidejs when its module loads. Lets `useRequest()` resolve
28
53
  * inside island renders and frames, not just `/__oxide/action`. */
29
54
  const OXIDE_RUN_WITH_REQUEST = Symbol.for("oxidejs.runWithRequest");
30
- /** Run `fn` with `request` available to `useContext().request`. When oxidejs
31
- * is loaded, its action scope is entered too, so `useRequest()` works in
32
- * island renders and streamed frames. */
33
- const runWithIslandRequest = (request, fn) => {
55
+ const innerAls = new AsyncLocalStorage();
56
+ /** Tracks the active fallback lock owner across awaits where ALS survives. */
57
+ const entryOwnerAls = new AsyncLocalStorage();
58
+ let syncStore = null;
59
+ /** When true, getStore ignores ALS so tests exercise the sync fallback path. */
60
+ let alsBypassForTests = false;
61
+ /** Serialize unrelated WebContainer fallback entries (set syncStore → run → clear). */
62
+ let fallbackTail = Promise.resolve(null);
63
+ /** Lock holder identity — nested reentry requires matching ALS owner (or sync nest). */
64
+ let activeOwner = null;
65
+ /**
66
+ * Sync-only nest depth while `runScoped` is invoking `fn`. Lets nested
67
+ * `runWithIslandRequest` reenter before `fn` awaits; cleared before awaiting
68
+ * so a suspended entry does not admit unrelated concurrent requests.
69
+ */
70
+ let syncNestDepth = 0;
71
+ const withFallbackEntry = async (fn) => {
72
+ const owner = alsBypassForTests ? void 0 : entryOwnerAls.getStore();
73
+ if (syncNestDepth > 0 || owner !== void 0 && owner === activeOwner) return await fn();
74
+ const previous = fallbackTail;
75
+ const next = Promise.withResolvers();
76
+ fallbackTail = next.promise;
77
+ await previous;
78
+ const myOwner = {};
79
+ activeOwner = myOwner;
80
+ try {
81
+ return await entryOwnerAls.run(myOwner, fn);
82
+ } finally {
83
+ activeOwner = null;
84
+ next.resolve(null);
85
+ }
86
+ };
87
+ const ensureScope = () => {
34
88
  const g = globalThis;
35
89
  const existing = g[REQUEST_ALS_KEY];
36
- let als;
37
- if (existing instanceof AsyncLocalStorage) als = existing;
38
- else {
39
- als = new AsyncLocalStorage();
40
- g[REQUEST_ALS_KEY] = als;
41
- }
42
- const oxideSlot = g[OXIDE_RUN_WITH_REQUEST];
90
+ if (existing && "getStore" in existing) return existing;
91
+ const scope = { getStore: () => (alsBypassForTests ? void 0 : innerAls.getStore()) ?? syncStore ?? void 0 };
92
+ g[REQUEST_ALS_KEY] = scope;
93
+ return scope;
94
+ };
95
+ const runScoped = (request, oxide, fn) => innerAls.run(request, () => oxide ? oxide(request, fn) : fn());
96
+ /**
97
+ * Run `fn` with `request` available to `useContext().request`. When oxidejs
98
+ * is loaded, its action scope is entered too, so `useRequest()` works in
99
+ * island renders and streamed frames.
100
+ *
101
+ * On WebContainer the return is always a `Promise` (fallback lock). Elsewhere
102
+ * the return matches `fn` (sync or async).
103
+ */
104
+ const runWithIslandRequest = (request, fn) => {
105
+ ensureScope();
106
+ const oxideSlot = globalThis[OXIDE_RUN_WITH_REQUEST];
43
107
  const oxide = oxideSlot === void 0 ? void 0 : oxideSlot;
44
- return als.run(request, () => oxide ? oxide(request, fn) : fn());
108
+ if (!inWebcontainer()) return runScoped(request, oxide, fn);
109
+ return withFallbackEntry(async () => {
110
+ const previous = syncStore;
111
+ syncStore = request;
112
+ try {
113
+ syncNestDepth += 1;
114
+ let result;
115
+ try {
116
+ result = runScoped(request, oxide, fn);
117
+ } finally {
118
+ syncNestDepth -= 1;
119
+ }
120
+ return await Promise.resolve(result);
121
+ } finally {
122
+ syncStore = previous;
123
+ }
124
+ });
45
125
  };
46
126
 
47
127
  //#endregion
package/dist/ssr.js CHANGED
@@ -1,3 +1,3 @@
1
- import { S as ssrWithImports, _ as registerServerIsland, a as authorizeFrameRequest, b as setFrameAuth, c as frameScopedUrl, d as getServerIslandEntry, f as isSafeFramePath, g as readBodyBounded, h as parseFrameProps, i as __ilhaServerAction, l as getFrameAuth, m as json, n as FrameError, o as forwardIdentityHeaders, p as isTrustedOrigin, r as MAX_BODY, s as frameEnvelope, t as FRAME_ENDPOINT, u as getFrameGuard, v as renderServerIsland, x as setFrameGuard, y as renderServerIslandResult } from "./ssr-Dvq_qXs4.js";
1
+ import { S as ssrWithImports, _ as registerServerIsland, a as authorizeFrameRequest, b as setFrameAuth, c as frameScopedUrl, d as getServerIslandEntry, f as isSafeFramePath, g as readBodyBounded, h as parseFrameProps, i as __ilhaServerAction, l as getFrameAuth, m as json, n as FrameError, o as forwardIdentityHeaders, p as isTrustedOrigin, r as MAX_BODY, s as frameEnvelope, t as FRAME_ENDPOINT, u as getFrameGuard, v as renderServerIsland, x as setFrameGuard, y as renderServerIslandResult } from "./ssr-D9q5C-OV.js";
2
2
 
3
3
  export { FRAME_ENDPOINT, FrameError, MAX_BODY, __ilhaServerAction, authorizeFrameRequest, ssrWithImports as default, forwardIdentityHeaders, frameEnvelope, frameScopedUrl, getFrameAuth, getFrameGuard, getServerIslandEntry, isSafeFramePath, isTrustedOrigin, json, parseFrameProps, readBodyBounded, registerServerIsland, renderServerIsland, renderServerIslandResult, setFrameAuth, setFrameGuard };
package/dist/vite.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as ilhaPages } from "./plugin-DCdQ9yO8.js";
1
+ import { t as ilhaPages } from "./plugin-Ci-W2rMt.js";
2
2
 
3
3
  //#region src/vite.ts
4
4
  /** Vite plugin — use via `@ilha/router/vite`. */
@@ -0,0 +1,9 @@
1
+ /**
2
+ * StackBlitz WebContainer detection for AsyncLocalStorage fallbacks.
3
+ * Safe to import from browser-shared modules: on the client this always
4
+ * returns false (no `process.versions.webcontainer`).
5
+ */
6
+ /** StackBlitz sets `process.versions.webcontainer` inside WebContainers. */
7
+ export declare const inWebcontainer: () => boolean;
8
+ /** Test-only: force or clear the WebContainer detection path. */
9
+ export declare const __setInWebcontainerForTests: (value: boolean | null) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ilha/router",
3
- "version": "0.11.5",
3
+ "version": "0.11.7",
4
4
  "description": "A tiny SPA router for Ilha",
5
5
  "keywords": [
6
6
  "frontend",
@@ -73,13 +73,13 @@
73
73
  "unplugin": "3.3.0"
74
74
  },
75
75
  "devDependencies": {
76
- "ilha": "^0.14.4",
77
- "oxidejs": "^0.3.1",
76
+ "ilha": "^0.14.5",
77
+ "oxidejs": "^0.3.2",
78
78
  "vite": "^8.2.2"
79
79
  },
80
80
  "peerDependencies": {
81
- "ilha": ">=0.14.4",
82
- "oxidejs": ">=0.3.1"
81
+ "ilha": ">=0.14.5",
82
+ "oxidejs": ">=0.3.2"
83
83
  },
84
84
  "peerDependenciesMeta": {
85
85
  "oxidejs": {