islands-runtime 0.1.0

The shared WASM runtime for islands.rs: reactive Signal/Scope/effect primitives and idempotent island mounting, emitted as islands_core.{js,wasm}.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
//! Two-tier prefetch (Phase 3f): warm the destination on hover / focus /
//! viewport-enter, then commit cheaply on click.
//!
//! ## Two tiers
//!
//! **Warm** (on `mouseenter` / `focusin` after a short dwell, or when the link
//! scrolls into view via an `IntersectionObserver`): a **bare `import(jsUrl)`**
//! — which warms the JS module graph and lets the browser fetch the referenced
//! `.wasm` — PLUS a headered HTML `fetch` whose response-text promise is parked
//! in a capped, TTL'd, abortable speculation cache (keyed by URL, each entry
//! owning its own `AbortController`). **Warm never calls `default()`**: no WASM
//! instantiation, no `start()`, no `register_island`, no `mount_all` (AC-V18 —
//! a hovered-not-clicked link leaves `SCOPE_REGISTRY` / `ISLAND_REGISTRY`
//! untouched).
//!
//! **Commit** (on click, driven from `mod.rs`): [`consume_speculation`]
//! synchronously removes the entry and hands its `AbortController` + parked body
//! promise to the active nav in a single run-to-completion step (before any
//! `await`), so neither the TTL evictor nor the active-nav abort can observe a
//! half-consumed entry (AC-V10 abort-scope rule). The active nav then runs
//! contract step 2's `await m.default()` (a cheap re-`import()` from cache + one
//! instantiation) and awaits the parked body — no duplicate fetch.
//!
//! ## Abort scope (AC-V10)
//!
//! Each speculation owns its own `AbortController`. The active-nav abort targets
//! only the *active* controller, never a cached speculation. Only un-consumed
//! speculations are abortable, and only by cap eviction or TTL expiry — at which
//! point their controller is aborted and their entry dropped. Consuming a
//! speculation transfers its controller out of the cache so it can never be
//! killed by the TTL or by a later active-nav abort.

use std::cell::RefCell;
use std::collections::HashMap;

use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;

use super::{dom, manifest, opt_out};

/// Maximum number of live speculations. Beyond this, the oldest is evicted (its
/// fetch aborted) before a new one is admitted — bounding memory and in-flight
/// requests under aggressive hover/scroll.
const MAX_SPECULATIONS: usize = 4;

/// How long an un-consumed speculation lives before it is evicted and its fetch
/// aborted, in milliseconds. Short enough that a stale prefetch does not linger.
const SPECULATION_TTL_MILLIS: i32 = 10_000;

/// Dwell before a hover/focus warms the destination, in milliseconds. A brief
/// dwell avoids warming every link the pointer merely passes over.
const HOVER_DWELL_MILLIS: i32 = 65;

/// A parked warm-tier speculation: the in-flight headered HTML fetch (as a
/// promise resolving to the response *text*), the controller that can abort it,
/// and the `setTimeout` handle for its TTL eviction.
///
/// The body is stored as a `js_sys::Promise` rather than an awaited `String` so
/// the fetch runs in the background and the commit path awaits it only if and
/// when the link is actually clicked.
pub(crate) struct Speculation {
    /// Resolves to the destination HTML text. Awaited by the commit path.
    body_text_promise: js_sys::Promise,
    /// Aborts the in-flight fetch. Transferred to the active nav on consume so
    /// the TTL evictor / active-nav abort can never kill a promoted fetch.
    controller: web_sys::AbortController,
    /// `window.setTimeout` handle for the TTL eviction; cleared on consume so a
    /// half-consumed entry can never be observed.
    ttl_timeout_handle: i32,
}

impl Speculation {
    /// The parked response-text promise (awaited by the active nav on commit).
    pub(crate) fn body_text_promise(&self) -> js_sys::Promise {
        self.body_text_promise.clone()
    }

    /// The controller the active nav adopts so it — not the cache — owns the
    /// fetch's lifetime after consume.
    pub(crate) fn into_controller(self) -> web_sys::AbortController {
        self.controller
    }
}

thread_local! {
    /// URL → parked speculation. The sole owner of each `Speculation`; removing
    /// an entry (consume or evict) drops it.
    ///
    /// Not a `const {}` initializer: `HashMap::new()` is not a `const fn`, so the
    /// thread-local can't be const-constructed (and clippy does not flag it).
    static SPECULATIONS: RefCell<HashMap<String, Speculation>> =
        RefCell::new(HashMap::new());

    /// Bundle JS URLs already bare-`import()`ed this session, so warming the
    /// same destination twice does not re-issue the dynamic import.
    ///
    /// Grows monotonically over a session (entries are never removed): a
    /// re-`import()` is what we are avoiding, so a URL stays "warmed" for the
    /// page's life. The set is bounded by the number of distinct route bundles —
    /// a small, fixed count — so the unbounded-looking growth is accepted. (Not a
    /// `const {}` initializer for the same reason as `SPECULATIONS`.)
    static WARMED_MODULES: RefCell<HashMap<String, ()>> = RefCell::new(HashMap::new());

    /// The pending hover-dwell `setTimeout` handle, if a dwell is in progress.
    /// A new hover/focus replaces it so only a sustained dwell warms.
    static HOVER_DWELL_HANDLE: RefCell<Option<i32>> = const { RefCell::new(None) };

    /// The nav-global viewport `IntersectionObserver`, kept alive past init so it
    /// can re-observe anchors that arrive in a later morph (see
    /// [`observe_current_anchors`]). `None` until [`attach_viewport_observer`]
    /// creates it.
    static VIEWPORT_OBSERVER: RefCell<Option<web_sys::IntersectionObserver>> =
        const { RefCell::new(None) };
}

/// Consume the speculation for `url`, if one is cached, in a single synchronous
/// step: remove the entry AND clear its TTL timer before returning. The caller
/// (the active nav's commit) then adopts the returned controller and awaits the
/// parked body — so the TTL evictor and the active-nav abort can never observe a
/// half-consumed entry (AC-V10).
///
/// Returns `None` on a cache miss; the caller then does a fresh fetch.
pub(crate) fn consume_speculation(url: &str) -> Option<Speculation> {
    SPECULATIONS.with(|cache| {
        let speculation = cache.borrow_mut().remove(url)?;
        clear_timeout(speculation.ttl_timeout_handle);
        Some(speculation)
    })
}

/// Whether `Save-Data` is requested, in which case all prefetch is suppressed
/// (warming would spend bytes the user asked us not to). Reads
/// `navigator.connection.saveData` defensively — absent / unsupported → `false`.
fn save_data_enabled() -> bool {
    let navigator = match dom::window() {
        Ok(window) => window.navigator(),
        Err(_) => return false,
    };
    let connection = match js_sys::Reflect::get(&navigator, &JsValue::from_str("connection")) {
        Ok(connection) if !connection.is_undefined() && !connection.is_null() => connection,
        _ => return false,
    };
    js_sys::Reflect::get(&connection, &JsValue::from_str("saveData"))
        .ok()
        .and_then(|value| value.as_bool())
        .unwrap_or(false)
}

/// Warm the destination of `anchor`: bare-`import()` its bundle (no `default()`)
/// and park a headered HTML fetch in the speculation cache. Suppressed when the
/// link is not interceptable (AC-V2 opt-outs), when `Save-Data` is on, or when
/// the route is unknown / already speculated.
///
/// This is the single warm entry point shared by the hover/focus and viewport
/// observers. It performs NO WASM instantiation (AC-V18).
fn warm_anchor(anchor: &web_sys::HtmlAnchorElement) {
    if save_data_enabled() {
        return;
    }
    // Respect every AC-V2 opt-out: never warm a link we would not intercept.
    let document_origin = match dom::document_origin() {
        Ok(origin) => origin,
        Err(_) => return,
    };
    // A synthetic primary-button, no-modifier click models "would this be
    // intercepted if clicked?" — reusing the one opt-out predicate.
    let link = dom::link_click_for_prefetch(anchor, document_origin);
    if !opt_out::should_intercept(&link) {
        return;
    }

    let url = anchor.href();
    let pathname = match pathname_of(&url) {
        Some(pathname) => pathname,
        None => return,
    };
    let entry = match manifest::resolved_entry(&pathname) {
        Some(entry) => entry,
        None => return,
    };

    warm_module(&entry.js);
    warm_fetch(&url);
}

/// Bare-`import()` a bundle's JS URL to warm the module graph + `.wasm` network,
/// at most once per URL per session. Deliberately does NOT call `default()`, so
/// no WASM is instantiated and no island is registered (AC-V18). Errors are
/// swallowed — a failed warm just means the commit pays full cost.
fn warm_module(js_url: &str) {
    let already = WARMED_MODULES.with(|warmed| {
        if warmed.borrow().contains_key(js_url) {
            return true;
        }
        warmed.borrow_mut().insert(js_url.to_owned(), ());
        false
    });
    if already {
        return;
    }
    // Fire-and-forget bare import; ignore the returned promise. No default().
    let _ = super::dynamic_import(js_url);
}

/// Start a headered HTML fetch for `url` and park its response-text promise in
/// the speculation cache under a fresh `AbortController` + TTL timer. A second
/// warm of the same URL is a no-op (the first speculation stands).
fn warm_fetch(url: &str) {
    let exists = SPECULATIONS.with(|cache| cache.borrow().contains_key(url));
    if exists {
        return;
    }
    if let Err(error) = start_speculation_fetch(url) {
        web_sys::console::warn_2(
            &JsValue::from_str("islands nav: prefetch warm fetch failed:"),
            &error,
        );
    }
}

/// Issue the headered fetch, build its response-text promise, install the TTL
/// eviction timer, and admit the speculation (evicting the oldest if at cap).
fn start_speculation_fetch(url: &str) -> Result<(), JsValue> {
    let controller = web_sys::AbortController::new()?;

    let headers = web_sys::Headers::new()?;
    headers.set(manifest::NAV_HEADER_NAME, manifest::NAV_HEADER_VALUE)?;
    let options = web_sys::RequestInit::new();
    options.set_headers(&headers);
    options.set_signal(Some(&controller.signal()));
    let request = web_sys::Request::new_with_str_and_init(url, &options)?;

    let window = dom::window()?;
    // Drive fetch → response.text() in a background future, exposed as a Promise
    // the commit path awaits on demand. The future starts running immediately
    // (the warm fetch is in flight before any click); a non-2xx response rejects
    // so commit falls back to a full load (AC-V11) rather than morphing an error
    // page. `future_to_promise` keeps the work alive without a leaked closure.
    let fetch_promise = window.fetch_with_request(&request);
    let body_text_promise = wasm_bindgen_futures::future_to_promise(async move {
        let response_value = wasm_bindgen_futures::JsFuture::from(fetch_promise).await?;
        let response: web_sys::Response = response_value.dyn_into()?;
        if !response.ok() {
            return Err(JsValue::from_str(&format!(
                "prefetch response not ok: HTTP {}",
                response.status()
            )));
        }
        wasm_bindgen_futures::JsFuture::from(response.text()?).await
    });

    let ttl_timeout_handle = install_ttl_eviction(&window, url)?;

    admit_speculation(
        url,
        Speculation {
            body_text_promise,
            controller,
            ttl_timeout_handle,
        },
    );
    Ok(())
}

/// Install a `setTimeout` that evicts (and aborts) the speculation for `url`
/// after the TTL. Returns the timeout handle so consume can cancel it.
fn install_ttl_eviction(window: &web_sys::Window, url: &str) -> Result<i32, JsValue> {
    let url_owned = url.to_owned();
    let evict = Closure::once_into_js(move || {
        evict_speculation(&url_owned);
    });
    window.set_timeout_with_callback_and_timeout_and_arguments_0(
        evict.unchecked_ref(),
        SPECULATION_TTL_MILLIS,
    )
}

/// Admit `speculation` for `url`, first evicting the oldest entry if the cache
/// is at capacity (its fetch aborted, its TTL timer cleared).
fn admit_speculation(url: &str, speculation: Speculation) {
    let to_evict = SPECULATIONS.with(|cache| {
        let cache_ref = cache.borrow();
        if cache_ref.len() < MAX_SPECULATIONS {
            return None;
        }
        // Evict an arbitrary existing entry (HashMap has no order; at this small
        // cap any victim bounds memory + in-flight requests acceptably).
        cache_ref.keys().next().cloned()
    });
    if let Some(victim) = to_evict {
        evict_speculation(&victim);
    }
    SPECULATIONS.with(|cache| {
        cache.borrow_mut().insert(url.to_owned(), speculation);
    });
}

/// Evict the speculation for `url`: abort its fetch and clear its TTL timer.
/// Used by the TTL timeout and by cap eviction. A consumed entry is already
/// gone, so this is a no-op for it.
fn evict_speculation(url: &str) {
    if let Some(speculation) = SPECULATIONS.with(|cache| cache.borrow_mut().remove(url)) {
        clear_timeout(speculation.ttl_timeout_handle);
        speculation.controller.abort();
    }
}

/// Attach the nav-global warm-tier listeners + viewport observer. Called once
/// from `nav::init` alongside the click interceptor — these are singletons, not
/// island-scoped. Idempotency is handled by the caller's init guard.
pub(crate) fn attach_warm_listeners() -> Result<(), JsValue> {
    attach_hover_focus_listeners()?;
    attach_viewport_observer()?;
    Ok(())
}

/// Capture-phase `mouseover` + `focusin` on `document`: after a short dwell,
/// warm the anchor under the pointer / focus. Capture phase + delegation keeps
/// these a single pair of document-level listeners regardless of link count.
fn attach_hover_focus_listeners() -> Result<(), JsValue> {
    let document = dom::document()?;

    let hover_handler = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {
        schedule_warm_from_event(&event);
    });
    document.add_event_listener_with_callback_and_bool(
        "mouseover",
        hover_handler.as_ref().unchecked_ref(),
        true,
    )?;
    hover_handler.forget();

    let focus_handler = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {
        schedule_warm_from_event(&event);
    });
    document.add_event_listener_with_callback_and_bool(
        "focusin",
        focus_handler.as_ref().unchecked_ref(),
        true,
    )?;
    focus_handler.forget();
    Ok(())
}

/// Resolve the anchor for a hover/focus `event` and, after [`HOVER_DWELL_MILLIS`],
/// warm it. A new hover/focus cancels the prior pending dwell so only a sustained
/// dwell warms.
fn schedule_warm_from_event(event: &web_sys::Event) {
    let target = match event.target() {
        Some(target) => target,
        None => return,
    };
    let anchor = match dom::closest_anchor(&target) {
        Some(anchor) => anchor,
        None => return,
    };

    // Cancel any pending dwell; this hover/focus supersedes it.
    cancel_pending_dwell();

    let window = match dom::window() {
        Ok(window) => window,
        Err(_) => return,
    };
    let dwell = Closure::once_into_js(move || {
        HOVER_DWELL_HANDLE.with(|handle| *handle.borrow_mut() = None);
        warm_anchor(&anchor);
    });
    if let Ok(handle) = window.set_timeout_with_callback_and_timeout_and_arguments_0(
        dwell.unchecked_ref(),
        HOVER_DWELL_MILLIS,
    ) {
        HOVER_DWELL_HANDLE.with(|stored| *stored.borrow_mut() = Some(handle));
    }
}

/// Cancel a pending hover-dwell timer, if one is scheduled.
fn cancel_pending_dwell() {
    HOVER_DWELL_HANDLE.with(|handle| {
        if let Some(pending) = handle.borrow_mut().take() {
            clear_timeout(pending);
        }
    });
}

/// Observe every `<a>` (delegated through a single `IntersectionObserver`) and
/// warm a link when it scrolls into view.
fn attach_viewport_observer() -> Result<(), JsValue> {
    let callback = Closure::<dyn FnMut(js_sys::Array)>::new(move |entries: js_sys::Array| {
        for entry_value in entries.iter() {
            let entry: web_sys::IntersectionObserverEntry = match entry_value.dyn_into() {
                Ok(entry) => entry,
                Err(_) => continue,
            };
            if !entry.is_intersecting() {
                continue;
            }
            if let Ok(anchor) = entry.target().dyn_into::<web_sys::HtmlAnchorElement>() {
                warm_anchor(&anchor);
            }
        }
    });
    let observer = web_sys::IntersectionObserver::new(callback.as_ref().unchecked_ref())?;
    callback.forget();

    // Keep the observer alive past init so a later morph can re-observe new
    // anchors through it (a dropped observer stops firing).
    VIEWPORT_OBSERVER.with(|slot| *slot.borrow_mut() = Some(observer));

    // Observe the anchors present on the initial page.
    observe_current_anchors();
    Ok(())
}

/// Observe every current `a[href]` through the nav-global viewport observer, so
/// links that scrolled into view warm their destination.
///
/// Called once at init and again after each successful morph (from the lifecycle
/// step-6 path) so anchors swapped in by a nav also get the viewport tier — AC-V16
/// lists viewport-enter as a warm trigger, and without re-observing it would only
/// work on the initial page. Idempotent: `IntersectionObserver::observe` on an
/// element already being observed is a no-op per the DOM spec, so re-scanning the
/// shared shell's links each nav does not double-warm. No-op until the observer
/// exists (i.e. before [`attach_viewport_observer`] has run).
pub(crate) fn observe_current_anchors() {
    VIEWPORT_OBSERVER.with(|slot| {
        let borrowed = slot.borrow();
        let observer = match borrowed.as_ref() {
            Some(observer) => observer,
            None => return,
        };
        let document = match dom::document() {
            Ok(document) => document,
            Err(_) => return,
        };
        let anchors = match document.query_selector_all("a[href]") {
            Ok(anchors) => anchors,
            Err(_) => return,
        };
        for index in 0..anchors.length() {
            if let Some(node) = anchors.get(index) {
                if let Ok(element) = node.dyn_into::<web_sys::Element>() {
                    observer.observe(&element);
                }
            }
        }
    });
}

/// `window.clearTimeout` for a handle, best-effort.
fn clear_timeout(handle: i32) {
    if let Ok(window) = dom::window() {
        window.clear_timeout_with_handle(handle);
    }
}

/// Pathname of a (possibly absolute) URL against the current base, or `None` if
/// it cannot be parsed. Mirrors `mod.rs::pathname_of` but returns `Option` for
/// the fire-and-forget warm path (a parse failure simply skips warming).
fn pathname_of(url: &str) -> Option<String> {
    let base = dom::window().ok()?.location().href().ok()?;
    web_sys::Url::new_with_base(url, &base)
        .ok()
        .map(|parsed| parsed.pathname())
}