@ilha/router 0.11.2 → 0.11.4

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/plugin.d.ts CHANGED
@@ -33,7 +33,7 @@ export interface IlhaPagesOptions {
33
33
  * when islands serve private data. Production equivalents register via
34
34
  * `setFrameGuard()` from `@ilha/router/ssr`.
35
35
  */
36
- frameGuard?: (request: Request) => Response | void | Promise<Response | void>;
36
+ frameGuard?: (request: Request) => Response | undefined | Promise<Response | undefined>;
37
37
  /**
38
38
  * Explicit trusted origins for frame/loader requests (e.g. a `.vercel.app`
39
39
  * or custom domain). When unset, origin checks compare the `Origin` header
@@ -51,28 +51,28 @@ export interface IlhaPagesOptions {
51
51
  */
52
52
  strict?: boolean;
53
53
  }
54
- export declare function resolvePluginPaths(root: string, options: IlhaPagesOptions): {
55
- pagesDir: string;
54
+ export declare const resolvePluginPaths: (root: string, options: IlhaPagesOptions) => {
55
+ clientFile: string;
56
56
  outDir: string;
57
+ pagesDir: string;
57
58
  serverFile: string;
58
- clientFile: string;
59
59
  };
60
60
  export interface PagesPluginState {
61
61
  pagesDir: string;
62
62
  outDir: string;
63
63
  serverFile: string;
64
64
  clientFile: string;
65
- setPaths(root: string): void;
66
- regen(): Promise<void>;
67
- shouldRegenOnChange(file: string): boolean;
68
- isUnderPagesDir(file: string): boolean;
65
+ setPaths: (root: string) => void;
66
+ regen: () => Promise<void>;
67
+ shouldRegenOnChange: (file: string) => boolean;
68
+ isUnderPagesDir: (file: string) => boolean;
69
69
  }
70
- export declare function createPagesPluginState(options: IlhaPagesOptions): PagesPluginState;
71
- export declare function regenFromPagesChange(state: PagesPluginState, file: string, shouldRegen: (file: string) => boolean): Promise<void>;
72
- export declare function resolvePagesId(state: PagesPluginState, id: string, importer?: string): string | undefined;
73
- export declare function loadPagesModule(state: PagesPluginState, id: string): string | undefined;
74
- type InvalidateModules = () => void | Promise<void>;
75
- export declare function createStructuralInvalidate(state: PagesPluginState, invalidate: InvalidateModules): (file: string) => Promise<void>;
76
- export declare function setupRspackPagesWatcher(state: PagesPluginState, structuralInvalidate: (file: string) => void | Promise<void>): () => void;
70
+ export declare const createPagesPluginState: (options: IlhaPagesOptions) => PagesPluginState;
71
+ export declare const regenFromPagesChange: (state: PagesPluginState, file: string, shouldRegen: (file: string) => boolean) => Promise<void>;
72
+ export declare const resolvePagesId: (state: PagesPluginState, id: string, importer?: string) => string | undefined;
73
+ export declare const loadPagesModule: (state: PagesPluginState, id: string) => string | undefined;
74
+ type InvalidateModules = () => Promise<void>;
75
+ export declare const createStructuralInvalidate: (state: PagesPluginState, invalidate: InvalidateModules) => (file: string) => Promise<void>;
76
+ export declare const setupRspackPagesWatcher: (state: PagesPluginState, structuralInvalidate: (file: string) => Promise<void>) => () => void;
77
77
  export declare const ilhaPages: import("unplugin").UnpluginInstance<IlhaPagesOptions | undefined, boolean>;
78
78
  export {};
@@ -13,11 +13,9 @@
13
13
  * storage without importing `node:async_hooks`; this node-only module is the
14
14
  * sole place that constructs it.
15
15
  */
16
- import { REQUEST_ALS_KEY } from "./als-key";
17
- import type { IslandContext } from "./index";
18
- export { REQUEST_ALS_KEY };
16
+ export { REQUEST_ALS_KEY } from "./als-key";
17
+ export type { IslandContext } from "./index";
19
18
  /** Run `fn` with `request` available to `useContext().request`. When oxidejs
20
19
  * is loaded, its action scope is entered too, so `useRequest()` works in
21
20
  * island renders and streamed frames. */
22
- export declare function runWithIslandRequest<T>(request: Request, fn: () => T): T;
23
- export type { IslandContext };
21
+ export declare const runWithIslandRequest: <T>(request: Request, fn: () => T) => T;
@@ -0,0 +1,6 @@
1
+ export declare class RouteError {
2
+ readonly __ilhaRouteError: true;
3
+ readonly status: number;
4
+ readonly message: string;
5
+ constructor(status: number, message: string);
6
+ }
@@ -11,12 +11,12 @@ export interface ParsedPattern {
11
11
  segments: string[];
12
12
  kinds: number[];
13
13
  }
14
- export declare function parsePattern(pattern: string): ParsedPattern;
14
+ export declare const parsePattern: (pattern: string) => ParsedPattern;
15
15
  /**
16
16
  * Match `segments` against a pathname. Returns raw (still-encoded) captured
17
17
  * params, or `null` when the path doesn't match — the caller decides whether to
18
18
  * decode via {@link safeDecode}.
19
19
  */
20
- export declare function matchSegments(segments: string[], pathname: string): Record<string, string> | null;
20
+ export declare const matchSegments: (segments: string[], pathname: string) => Record<string, string> | null;
21
21
  /** Decode a route-param value, tolerating malformed percent-encoding. */
22
- export declare function safeDecode(value: string): string;
22
+ export declare const safeDecode: (value: string) => string;
package/dist/rsbuild.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- export type { LayoutHandler, ErrorHandler, RouteSnapshot, AppError } from "./index";
1
+ import type { IlhaPagesOptions } from "./plugin";
2
+ export type { LayoutHandler, ErrorHandler, RouteSnapshot, AppError, } from "./index";
2
3
  export { ilhaPages, type IlhaPagesOptions } from "./plugin";
3
- import { type IlhaPagesOptions } from "./plugin";
4
4
  /** Rsbuild plugin — use via `@ilha/router/rsbuild`. */
5
- export declare function pages(options?: IlhaPagesOptions): any;
5
+ export declare const pages: (options?: IlhaPagesOptions) => any;
6
6
  export default pages;
package/dist/rsbuild.js CHANGED
@@ -1,10 +1,8 @@
1
- import { t as ilhaPages } from "./plugin-Bfnn44nu.js";
1
+ import { t as ilhaPages } from "./plugin-DCdQ9yO8.js";
2
2
 
3
3
  //#region src/rsbuild.ts
4
4
  /** Rsbuild plugin — use via `@ilha/router/rsbuild`. */
5
- function pages(options = {}) {
6
- return ilhaPages.rsbuild(options);
7
- }
5
+ const pages = (options = {}) => ilhaPages.rsbuild(options);
8
6
 
9
7
  //#endregion
10
8
  export { pages as default, pages, ilhaPages };
@@ -14,27 +14,35 @@
14
14
  * `[data-ilha-on]` event sentinels to named actions using the
15
15
  * `data-ilha-actions` manifest emitted by `hydratable()`.
16
16
  */
17
+ import type { SnapshotObject, SnapshotValue } from "./snapshot";
18
+ /** Symbol.for keeps brands stable across duplicate ilha copies in one realm. */
19
+ declare const ISLAND: unique symbol;
20
+ declare const ISLAND_SLOT_TAG: unique symbol;
21
+ export declare const ISLAND_MOUNT_INTERNAL: unique symbol;
17
22
  declare const ISLAND_CALL: unique symbol;
18
- /** @internal Repaint every mounted island from the same `*.server` module. */
19
- export declare function __ilhaRepaintServerModule(moduleKey: string): void;
20
- export type ServerStreamFn = (signal: AbortSignal) => AsyncGenerator<unknown> | Generator<unknown>;
23
+ /** @internal */
24
+ export declare const __ilhaRepaintServerModule: (moduleKey: string) => void;
25
+ export type ServerStreamFn = (signal: AbortSignal) => AsyncGenerator<SnapshotValue> | Generator<SnapshotValue>;
26
+ /** JSON-serializable value — the wire format for server-action payloads. */
27
+ export type ServerActionPayload = SnapshotValue;
28
+ export type ServerIslandProps = SnapshotObject;
21
29
  export interface ServerIslandWiring {
22
30
  /** Stream key → client transport. The plugin wires these to tacho stubs. */
23
31
  streams?: Record<string, ServerStreamFn>;
24
32
  /** Action key → client transport. Event payloads are not serializable;
25
33
  * handlers receive `undefined` and should read island state instead. */
26
- actions?: Record<string, (payload?: unknown) => unknown>;
34
+ actions?: Record<string, (payload?: ServerActionPayload) => SnapshotValue | Promise<SnapshotValue>>;
27
35
  /** Frame transport: re-renders the island with the latest parent props. */
28
- frame?: (props?: Record<string, unknown>) => unknown;
36
+ frame?: (props?: ServerIslandProps) => string | Promise<string> | SnapshotValue;
29
37
  /** Client-capable islands nested in the server render, keyed by opaque ref. */
30
- children?: Record<string, unknown>;
38
+ children?: Record<string, ServerIslandCallable>;
31
39
  }
32
40
  export interface ServerIslandHandle {
33
41
  unmount: () => void;
34
- updateProps: (props?: Record<string, unknown>) => void;
42
+ updateProps: (props?: ServerIslandProps) => void;
35
43
  }
36
- /** @internal Apply head entries returned with a server-page frame. */
37
- export declare function __ilhaApplyHead(entries: unknown): void;
44
+ /** @internal */
45
+ export declare const __ilhaApplyHead: <T>(entries: T) => void;
38
46
  /**
39
47
  * Create a client proxy island for a server-defined island. Called by
40
48
  * generated virtual modules — not by application code.
@@ -43,16 +51,19 @@ export declare function __ilhaApplyHead(entries: unknown): void;
43
51
  * @param as - Slot tag declared by the server island's `{ as }` option (default div).
44
52
  * @param wiring - Stream/action transports wired to tacho stubs by codegen.
45
53
  */
46
- interface IslandCallShape {
54
+ interface IslandCallPayload {
47
55
  [ISLAND_CALL]: true;
48
56
  island: ServerIslandCallable;
49
- props?: Record<string, unknown>;
57
+ props?: ServerIslandProps;
50
58
  key: string;
51
59
  }
52
- export type ServerIslandCallable = Record<symbol, unknown> & ((props?: Record<string, unknown>) => string) & {
53
- mount: (host: Element, props?: Record<string, unknown>) => () => void;
60
+ export type ServerIslandCallable = ((props?: ServerIslandProps) => string) & {
61
+ [ISLAND]: true;
62
+ [ISLAND_SLOT_TAG]: string;
63
+ mount: (host: Element, props?: ServerIslandProps) => () => void;
54
64
  toString: () => string;
55
- key: (slotKey: string) => (props?: Record<string, unknown>) => IslandCallShape;
65
+ key: (slotKey: string) => (props?: ServerIslandProps) => IslandCallPayload;
66
+ [ISLAND_MOUNT_INTERNAL]: (host: Element, props?: ServerIslandProps) => ServerIslandHandle;
56
67
  };
57
- export declare function __ilhaServerIsland(id: string, as: string, wiring?: ServerIslandWiring, moduleKey?: string): ServerIslandCallable;
68
+ export declare const __ilhaServerIsland: (id: string, as: string, wiring?: ServerIslandWiring, moduleKey?: string) => ServerIslandCallable;
58
69
  export {};
@@ -1,5 +1,5 @@
1
- import { t as applyHeadEntriesToDocument } from "./head-DpYV3bHH.js";
2
- import { t as parseSnapshotAttr } from "./snapshot-C0E2OGwL.js";
1
+ import { t as applyHeadEntriesToDocument } from "./head-BLtm0xtK.js";
2
+ import { t as parseSnapshotAttr } from "./snapshot-XpAkSV8s.js";
3
3
 
4
4
  //#region src/server-island.ts
5
5
  /**
@@ -18,21 +18,25 @@ import { t as parseSnapshotAttr } from "./snapshot-C0E2OGwL.js";
18
18
  * `[data-ilha-on]` event sentinels to named actions using the
19
19
  * `data-ilha-actions` manifest emitted by `hydratable()`.
20
20
  */
21
+ const objectTag = (value) => Object.prototype.toString.call(value);
22
+ const isString = (value) => objectTag(value) === "[object String]";
23
+ const isObject = (value) => value !== null && objectTag(value) === "[object Object]";
21
24
  /** Symbol.for keeps brands stable across duplicate ilha copies in one realm. */
22
25
  const ISLAND = Symbol.for("ilha.island");
23
26
  const ISLAND_SLOT_TAG = Symbol.for("ilha.islandSlotTag");
24
27
  const ISLAND_MOUNT_INTERNAL = Symbol.for("ilha.islandMountInternal");
28
+ const ISLAND_CALL = Symbol.for("ilha.islandCall");
25
29
  const STATE_ATTR = "data-ilha-state";
26
30
  const EVENT_SENTINEL_ATTR = "data-ilha-on";
27
31
  const ACTIONS_ATTR = "data-ilha-actions";
28
32
  const PROPS_ATTR = "data-ilha-props";
29
33
  const CLIENT_REF_ATTR = "data-ilha-client-ref";
30
34
  const moduleRepaints = /* @__PURE__ */ new Map();
31
- /** @internal Repaint every mounted island from the same `*.server` module. */
32
- function __ilhaRepaintServerModule(moduleKey) {
35
+ /** @internal */
36
+ const __ilhaRepaintServerModule = (moduleKey) => {
33
37
  for (const repaint of moduleRepaints.get(moduleKey) ?? []) repaint();
34
- }
35
- function linkServerModuleRepaints(moduleKey, repaint) {
38
+ };
39
+ const linkServerModuleRepaints = (moduleKey, repaint) => {
36
40
  let set = moduleRepaints.get(moduleKey);
37
41
  if (!set) {
38
42
  set = /* @__PURE__ */ new Set();
@@ -40,72 +44,97 @@ function linkServerModuleRepaints(moduleKey, repaint) {
40
44
  }
41
45
  set.add(repaint);
42
46
  return () => {
43
- set.delete(repaint);
44
- if (set.size === 0) moduleRepaints.delete(moduleKey);
47
+ const live = moduleRepaints.get(moduleKey);
48
+ if (!live) return;
49
+ live.delete(repaint);
50
+ if (live.size === 0) moduleRepaints.delete(moduleKey);
45
51
  };
46
- }
47
- /** @internal Apply head entries returned with a server-page frame. */
48
- function __ilhaApplyHead(entries) {
52
+ };
53
+ /** @internal */
54
+ const __ilhaApplyHead = (entries) => {
49
55
  if (Array.isArray(entries)) applyHeadEntriesToDocument(entries);
50
- }
56
+ };
51
57
  /** Defensive snapshot parse — reuses the shared guarded parser (size cap,
52
58
  * plain-object check, depth cap, prototype-key stripping). */
53
- function assertValidTag(tag) {
59
+ const assertValidTag = (tag) => {
54
60
  const trimmed = tag.trim();
55
- if (/^[a-z][a-z0-9-]*$/i.test(trimmed)) return trimmed.toLowerCase();
61
+ if (/^[a-z][a-z0-9-]*$/iu.test(trimmed)) return trimmed.toLowerCase();
56
62
  return "div";
57
- }
63
+ };
58
64
  /** True when `candidate` is owned by `host`: walking up must not cross another
59
65
  * island or slot boundary before reaching it. */
60
- function belongsToHost(host, candidate) {
66
+ const belongsToHost = (host, candidate) => {
61
67
  let el = candidate.parentElement;
62
68
  while (el && el !== host) {
63
- if (el.hasAttribute("data-ilha")) return false;
64
- if (el.hasAttribute("data-ilha-slot") && el.style.display !== "contents") return false;
69
+ if (el.matches("[data-ilha]")) return false;
70
+ if (el.matches("[data-ilha-slot]") && el instanceof HTMLElement && el.style.display !== "contents") return false;
65
71
  el = el.parentElement;
66
72
  }
67
73
  return el === host;
68
- }
69
- /** Dev cold-start can race a warming `virtual:oxide/actions` graph — retry briefly. */
70
- async function startServerStream(fn, signal) {
71
- let last;
72
- for (let attempt = 0; attempt < 4; attempt++) {
73
- if (signal.aborted) throw last ?? new DOMException("The operation was aborted.", "AbortError");
74
- let gen;
75
- try {
76
- gen = await fn(signal);
77
- const first = await gen.next();
78
- return {
79
- gen,
80
- first
81
- };
82
- } catch (err) {
83
- last = err;
84
- if (gen) Promise.resolve(gen.return?.(void 0)).catch(() => {});
85
- if (signal.aborted) throw err;
86
- const msg = String(err);
87
- if (!msg.includes("404") && !msg.includes("503")) throw err;
88
- if (attempt === 3) throw err;
89
- await new Promise((resolve, reject) => {
90
- const timer = setTimeout(() => {
91
- signal.removeEventListener("abort", onAbort);
92
- resolve();
93
- }, 50 * (attempt + 1));
94
- const onAbort = () => {
95
- clearTimeout(timer);
96
- reject(err);
97
- };
98
- signal.addEventListener("abort", onAbort, { once: true });
99
- if (signal.aborted) onAbort();
100
- });
74
+ };
75
+ const sleepMs = (ms) => {
76
+ const { promise, resolve } = Promise.withResolvers();
77
+ setTimeout(() => {
78
+ resolve(null);
79
+ }, ms);
80
+ return promise;
81
+ };
82
+ const abortAwareDelay = async (ms, signal, abortError) => {
83
+ if (signal.aborted) throw abortError;
84
+ await sleepMs(ms);
85
+ if (signal.aborted) throw abortError;
86
+ };
87
+ const isRetryableRpcFailure = (error) => {
88
+ const msg = String(error);
89
+ return msg.includes("404") || msg.includes("503") || msg.includes("empty HTTP response") || msg.includes("HttpError") || msg.includes("RpcClientDefect") || msg.includes("RpcClientError") || msg.includes("TransportError") || msg.includes("InvalidUrlError");
90
+ };
91
+ const isAbortLike = (error, signal) => {
92
+ if (signal.aborted) return true;
93
+ return error.name === "AbortError";
94
+ };
95
+ const closeStream = async (gen) => {
96
+ if (!gen) return;
97
+ try {
98
+ await gen.return?.(void 0);
99
+ } catch {}
100
+ };
101
+ const pullStream = async (gen, step, key, state, signal, onValue) => {
102
+ if (signal.aborted || step.done) return;
103
+ state[key] = step.value;
104
+ onValue();
105
+ const next = await gen.next();
106
+ await pullStream(gen, next, key, state, signal, onValue);
107
+ };
108
+ /**
109
+ * Pull a wired server stream until it ends or the island unmounts.
110
+ * Dev cold-start and flaky Oxide RPC transports (empty bodies, aborted
111
+ * fetches during Vite dep reloads) are retried with the same generator
112
+ * factory — including failures after the first chunk.
113
+ */
114
+ const resumeServerStream = async (fn, key, state, controller, onValue, attempt = 0) => {
115
+ if (controller.signal.aborted) return;
116
+ let gen;
117
+ try {
118
+ gen = await fn(controller.signal);
119
+ const first = await gen.next();
120
+ await pullStream(gen, first, key, state, controller.signal, onValue);
121
+ await closeStream(gen);
122
+ } catch (error) {
123
+ await closeStream(gen);
124
+ if (isAbortLike(error, controller.signal)) return;
125
+ if (!isRetryableRpcFailure(error) || attempt >= 3) {
126
+ console.error(`[ilha-router] stream "${key}" failed:`, error);
127
+ return;
101
128
  }
129
+ await abortAwareDelay(50 * (attempt + 1), controller.signal, error);
130
+ return resumeServerStream(fn, key, state, controller, onValue, attempt + 1);
102
131
  }
103
- throw last;
104
- }
105
- function hydrateServerIsland(host, id, wiring, props, moduleKey) {
132
+ };
133
+ const hydrateServerIsland = (host, id, wiring, props, moduleKey) => {
106
134
  const controller = new AbortController();
107
- const cleanups = [];
108
- cleanups.push(() => controller.abort());
135
+ const cleanups = [() => {
136
+ controller.abort();
137
+ }];
109
138
  const state = {};
110
139
  const rawState = host.getAttribute(STATE_ATTR);
111
140
  if (rawState) {
@@ -114,30 +143,12 @@ function hydrateServerIsland(host, id, wiring, props, moduleKey) {
114
143
  for (const [key, value] of Object.entries(parsed)) if (!key.startsWith("_")) state[key] = value;
115
144
  }
116
145
  }
117
- const frame = wiring.frame;
146
+ const { frame } = wiring;
118
147
  let currentProps = props;
119
148
  let repaintChain = Promise.resolve();
120
- const scheduleRepaint = () => {
121
- if (!frame || controller.signal.aborted) return;
122
- const sent = currentProps;
123
- repaintChain = repaintChain.then(async () => {
124
- if (controller.signal.aborted || !host.isConnected) return;
125
- const html = await frame(sent);
126
- if (controller.signal.aborted || typeof html !== "string") return;
127
- const parsed = new DOMParser().parseFromString(`<div>${html}</div>`, "text/html");
128
- host.replaceChildren(...Array.from(parsed.body.firstElementChild?.childNodes ?? []));
129
- syncChildren();
130
- wireEvents();
131
- }).catch((err) => {
132
- if (!controller.signal.aborted) console.error(`[ilha-router] frame render failed for "${id}":`, err);
133
- });
134
- };
135
- const repaintModule = () => {
136
- if (moduleKey) __ilhaRepaintServerModule(moduleKey);
137
- else scheduleRepaint();
138
- };
139
- if (moduleKey) cleanups.push(linkServerModuleRepaints(moduleKey, scheduleRepaint));
140
149
  const attached = [];
150
+ const mountedChildren = /* @__PURE__ */ new Map();
151
+ const hooks = { repaintModule: () => {} };
141
152
  const readManifest = () => {
142
153
  const raw = host.querySelector(`:scope > template[${ACTIONS_ATTR}], template[${ACTIONS_ATTR}]`)?.getAttribute(ACTIONS_ATTR) ?? host.getAttribute(ACTIONS_ATTR) ?? null;
143
154
  return raw ? parseSnapshotAttr(raw) : void 0;
@@ -147,7 +158,7 @@ function hydrateServerIsland(host, id, wiring, props, moduleKey) {
147
158
  attached.length = 0;
148
159
  const manifest = readManifest();
149
160
  if (!manifest) return;
150
- const sentinels = [host, ...Array.from(host.querySelectorAll(`[${EVENT_SENTINEL_ATTR}]`))];
161
+ const sentinels = [host, ...host.querySelectorAll(`[${EVENT_SENTINEL_ATTR}]`)];
151
162
  for (const el of sentinels) {
152
163
  if (!el.hasAttribute(EVENT_SENTINEL_ATTR)) continue;
153
164
  if (el !== host && !belongsToHost(host, el)) continue;
@@ -155,48 +166,60 @@ function hydrateServerIsland(host, id, wiring, props, moduleKey) {
155
166
  for (const part of spec.split(",")) {
156
167
  const sep = part.lastIndexOf(":");
157
168
  if (sep < 1) continue;
158
- const type = part.slice(0, sep);
169
+ const eventType = part.slice(0, sep);
159
170
  const entry = manifest[part];
160
171
  let actionKey;
161
172
  let callArgs = [];
162
- if (typeof entry === "string") actionKey = entry;
163
- else if (entry && typeof entry === "object") {
173
+ if (isString(entry)) actionKey = entry;
174
+ else if (entry && isObject(entry)) {
164
175
  actionKey = String(entry.k);
165
176
  if (Array.isArray(entry.a)) callArgs = entry.a;
166
177
  }
167
- const action = actionKey == null ? void 0 : wiring.actions?.[actionKey];
168
- if (!action) continue;
178
+ const actionFn = actionKey === null || actionKey === void 0 ? void 0 : wiring.actions?.[actionKey];
179
+ if (!actionFn) continue;
180
+ const boundKey = actionKey;
169
181
  const listener = () => {
170
- Promise.resolve(action(...callArgs)).then(() => repaintModule()).catch((err) => {
171
- console.error(`[ilha-router] action "${String(actionKey)}" failed:`, err);
172
- });
182
+ const run = async () => {
183
+ try {
184
+ await Promise.resolve(actionFn(...callArgs));
185
+ hooks.repaintModule();
186
+ } catch (error) {
187
+ console.error(`[ilha-router] action "${String(boundKey)}" failed:`, error);
188
+ }
189
+ };
190
+ run();
173
191
  };
174
- el.addEventListener(type, listener);
192
+ el.addEventListener(eventType, listener);
175
193
  attached.push({
176
194
  el,
177
- type,
178
- listener
195
+ listener,
196
+ type: eventType
179
197
  });
180
198
  }
181
199
  }
182
200
  };
183
- wireEvents();
184
- const mountedChildren = /* @__PURE__ */ new Map();
185
- const reviveChildProps = (props) => {
186
- if (!props) return void 0;
187
- for (const [key, value] of Object.entries(props)) {
188
- if (!value || typeof value !== "object") continue;
201
+ const reviveChildProps = (childProps) => {
202
+ if (!childProps) return;
203
+ const next = { ...childProps };
204
+ for (const [key, value] of Object.entries(childProps)) {
205
+ if (!value || !isObject(value)) continue;
189
206
  const marker = value;
190
- if (marker.__ilha !== "action" || typeof marker.k !== "string") continue;
191
- const action = wiring.actions?.[marker.k];
192
- if (!action) continue;
193
- const args = Array.isArray(marker.a) ? marker.a : [];
194
- props[key] = (..._runtimeArgs) => Promise.resolve(action(...args)).then((result) => {
195
- repaintModule();
196
- return result;
197
- });
207
+ if (marker.__ilha !== "action" || !isString(marker.k)) continue;
208
+ const actionFn = wiring.actions?.[marker.k];
209
+ if (!actionFn) continue;
210
+ let args = [];
211
+ if (Array.isArray(marker.a)) args = marker.a;
212
+ const revived = (..._runtimeArgs) => {
213
+ const run = async () => {
214
+ const result = await Promise.resolve(actionFn(...args));
215
+ hooks.repaintModule();
216
+ return result;
217
+ };
218
+ return run();
219
+ };
220
+ Object.assign(next, { [key]: revived });
198
221
  }
199
- return props;
222
+ return next;
200
223
  };
201
224
  const syncChildren = () => {
202
225
  for (const [el, handle] of mountedChildren) {
@@ -206,38 +229,46 @@ function hydrateServerIsland(host, id, wiring, props, moduleKey) {
206
229
  }
207
230
  for (const el of host.querySelectorAll(`[${CLIENT_REF_ATTR}]`)) {
208
231
  if (!belongsToHost(host, el)) continue;
209
- const props = reviveChildProps(parseSnapshotAttr(el.getAttribute(PROPS_ATTR) ?? "") ?? void 0);
232
+ const childProps = reviveChildProps(parseSnapshotAttr(el.getAttribute(PROPS_ATTR) ?? "") ?? void 0);
210
233
  const mounted = mountedChildren.get(el);
211
234
  if (mounted) {
212
- mounted.updateProps(props);
235
+ mounted.updateProps(childProps);
213
236
  continue;
214
237
  }
215
238
  const mount = (wiring.children?.[el.getAttribute(CLIENT_REF_ATTR) ?? ""])?.[ISLAND_MOUNT_INTERNAL];
216
- if (mount) mountedChildren.set(el, mount(el, props));
239
+ if (mount) mountedChildren.set(el, mount(el, childProps));
217
240
  }
218
241
  };
219
- syncChildren();
220
- for (const [key, fn] of Object.entries(wiring.streams ?? {})) (async () => {
221
- try {
222
- const { gen, first } = await startServerStream(fn, controller.signal);
242
+ const applyFrameHtml = async (sent) => {
243
+ if (!frame || controller.signal.aborted || !host.isConnected) return;
244
+ const html = await frame(sent);
245
+ if (controller.signal.aborted || !isString(html)) return;
246
+ const parsed = new DOMParser().parseFromString(`<div>${html}</div>`, "text/html");
247
+ host.replaceChildren(...parsed.body.firstElementChild?.childNodes ?? []);
248
+ syncChildren();
249
+ wireEvents();
250
+ };
251
+ const scheduleRepaint = () => {
252
+ if (!frame || controller.signal.aborted) return;
253
+ const sent = currentProps;
254
+ const previous = repaintChain;
255
+ repaintChain = (async () => {
256
+ await previous;
223
257
  try {
224
- let step = first;
225
- for (;;) {
226
- const { done, value } = step;
227
- if (controller.signal.aborted || done) break;
228
- state[key] = value;
229
- repaintModule();
230
- step = await gen.next();
231
- }
232
- } catch (err) {
233
- if (!controller.signal.aborted && err?.name !== "AbortError") console.error(`[ilha-router] stream "${key}" failed:`, err);
234
- } finally {
235
- Promise.resolve(gen.return?.(void 0)).catch(() => {});
258
+ await applyFrameHtml(sent);
259
+ } catch (error) {
260
+ if (!controller.signal.aborted) console.error(`[ilha-router] frame render failed for "${id}":`, error);
236
261
  }
237
- } catch (err) {
238
- if (!controller.signal.aborted && err?.name !== "AbortError") console.error(`[ilha-router] stream "${key}" failed:`, err);
239
- }
240
- })();
262
+ })();
263
+ };
264
+ hooks.repaintModule = () => {
265
+ if (moduleKey) __ilhaRepaintServerModule(moduleKey);
266
+ else scheduleRepaint();
267
+ };
268
+ if (moduleKey) cleanups.push(linkServerModuleRepaints(moduleKey, scheduleRepaint));
269
+ wireEvents();
270
+ syncChildren();
271
+ for (const [key, fn] of Object.entries(wiring.streams ?? {})) resumeServerStream(fn, key, state, controller, hooks.repaintModule);
241
272
  if (frame && !host.hasAttribute(STATE_ATTR) && host.childNodes.length === 0) scheduleRepaint();
242
273
  return {
243
274
  unmount: () => {
@@ -253,8 +284,8 @@ function hydrateServerIsland(host, id, wiring, props, moduleKey) {
253
284
  scheduleRepaint();
254
285
  }
255
286
  };
256
- }
257
- function __ilhaServerIsland(id, as, wiring = {}, moduleKey) {
287
+ };
288
+ const __ilhaServerIsland = (id, as, wiring = {}, moduleKey) => {
258
289
  const slotTag = assertValidTag(as);
259
290
  const island = ((props) => {
260
291
  return "";
@@ -262,20 +293,19 @@ function __ilhaServerIsland(id, as, wiring = {}, moduleKey) {
262
293
  island[ISLAND] = true;
263
294
  island[ISLAND_SLOT_TAG] = slotTag;
264
295
  island.toString = () => "";
265
- const ISLAND_CALL = Symbol.for("ilha.islandCall");
266
296
  island.key = (slotKey) => {
267
- if (typeof slotKey !== "string" || slotKey.trim().length === 0 || slotKey.includes(":")) throw new Error("server island key() requires a non-empty key without \":\".");
268
- return (props) => ({
297
+ if (!isString(slotKey) || slotKey.trim().length === 0 || slotKey.includes(":")) throw new Error("server island key() requires a non-empty key without \":\".");
298
+ return (callProps) => ({
269
299
  [ISLAND_CALL]: true,
270
300
  island,
271
- props,
272
- key: slotKey
301
+ key: slotKey,
302
+ props: callProps
273
303
  });
274
304
  };
275
305
  island[ISLAND_MOUNT_INTERNAL] = (host, mountProps) => hydrateServerIsland(host, id, wiring, mountProps, moduleKey);
276
306
  island.mount = (host, mountProps) => hydrateServerIsland(host, id, wiring, mountProps, moduleKey).unmount;
277
307
  return island;
278
- }
308
+ };
279
309
 
280
310
  //#endregion
281
- export { __ilhaApplyHead, __ilhaRepaintServerModule, __ilhaServerIsland };
311
+ export { ISLAND_MOUNT_INTERNAL, __ilhaApplyHead, __ilhaRepaintServerModule, __ilhaServerIsland };