fix(service-worker): allow navigation preload during activation

Recognize the activating version while the runtime keeps it in the waiting
slot until activate settles. NavigationPreloadManager can now enable,
disable, read state and set its header during initial activation, and the
state is persisted when activation completes.

Cover the inactive/activating boundary and persistence in a runtime test,
plus a real iframe navigation served through event.preloadResponse.

Validation: cargo fmt --all; cargo clippy --workspace --all-targets
--all-features -- -D warnings; cargo nextest run --no-fail-fast
(18,933 passed, 16 skipped). The four selected navigation-preload WPT cases
improve from 1 pass / 1 fail / 2 timeout to 3 pass / 1 fail; both the same-site
iframe and empty preload body cases now pass.
This commit is contained in:
ldm0
2026-09-29 15:50:34 +08:00
parent 46c39c6321
commit 8ba39c4aa1
3 changed files with 180 additions and 6 deletions
@@ -3028,6 +3028,87 @@ async fn navigator_service_worker_event_listeners_do_not_drive_lifecycle_state()
.await
.expect("service worker listener script server should finish");
}
#[tokio::test]
async fn navigator_service_worker_navigation_preload_enabled_during_activate_serves_iframe() {
let (base_url, server) = spawn_service_worker_response_server(vec![
(
"/app/worker.js",
"text/javascript; charset=utf-8",
r#"
self.addEventListener("activate", event => {
event.waitUntil((async () => {
const manager = self.registration.navigationPreload;
await manager.enable();
await manager.disable();
await manager.setHeaderValue("activate-preload");
await manager.enable();
self.preloadStateDuringActivate = await manager.getState();
})());
});
self.addEventListener("fetch", event => {
event.respondWith((async () => {
const response = await event.preloadResponse;
const result = {
duringActivate: self.preloadStateDuringActivate,
mode: event.request.mode,
destination: event.request.destination,
body: await response.text()
};
return new Response(
"<script>parent.postMessage(" + JSON.stringify(result) + ", '*')</script>",
{headers: {"Content-Type": "text/html"}}
);
})());
});
"#,
),
("/app/frame.html", "text/plain", "preloaded-body"),
])
.await;
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader");
let (mut vm, browser_context_runtime) =
new_service_worker_page_test_vm_with_loader_and_browser_context_runtime(
&format!("{base_url}/app/page.html"),
&loader,
);
vm.eval(
r#"
globalThis.preloadIframeResult = "pending";
(async () => {
const registration = await navigator.serviceWorker.register("worker.js", {scope: "./"});
await navigator.serviceWorker.ready;
const afterActivate = await registration.navigationPreload.getState();
if (!afterActivate.enabled) throw new Error("activate did not enable preload");
const iframe = document.createElement("iframe");
const message = new Promise(resolve => {
addEventListener("message", event => resolve(event.data), {once: true});
});
iframe.src = "frame.html";
document.body.appendChild(iframe);
preloadIframeResult = JSON.stringify({afterActivate, response: await message});
iframe.remove();
await registration.unregister();
})().catch(error => { preloadIframeResult = String(error); });
"#,
)
.expect("navigation preload iframe test should schedule");
drain_service_worker_test_until_eval_equals(
&mut vm,
&browser_context_runtime,
&loader,
"String(preloadIframeResult !== 'pending')",
"true",
)
.await;
assert_eq!(
vm.eval("preloadIframeResult").unwrap(),
r#"{"afterActivate":{"enabled":true,"headerValue":"activate-preload"},"response":{"duringActivate":{"enabled":true,"headerValue":"activate-preload"},"mode":"navigate","destination":"iframe","body":"preloaded-body"}}"#
);
server
.await
.expect("navigation preload server should finish");
}
#[tokio::test]
async fn navigator_service_worker_navigation_preload_state_shared_with_worker() {
let (base_url, server) = spawn_service_worker_response_server(vec![(
@@ -1,5 +1,21 @@
use super::*;
fn navigation_preload_version_id(
state: &ServiceWorkerRuntimeState,
registration: &ServiceWorkerRegistration,
) -> Option<ServiceWorkerVersionId> {
registration.active_version_id.or_else(|| {
// The runtime retains an activating version in the waiting slot until
// its activate event settles. NavigationPreloadManager must already
// be usable inside that event, including promises passed to waitUntil.
registration.waiting_version_id.filter(|version_id| {
state.versions.get(version_id).is_some_and(|version| {
version.lifecycle_state == ServiceWorkerVersionLifecycleState::Activating
})
})
})
}
impl ServiceWorkerRuntimeService {
#[cfg(test)]
pub(crate) fn watch_ready_registration(
@@ -120,7 +136,7 @@ impl ServiceWorkerRuntimeService {
.find(|registration| {
registration.scope_url == *scope_url
&& !registration.pending_unregistration
&& registration.active_version_id.is_some()
&& navigation_preload_version_id(&state, registration).is_some()
})
.map(|registration| registration.navigation_preload_state.clone())
}
@@ -161,16 +177,20 @@ impl ServiceWorkerRuntimeService {
else {
return Err(ServiceWorkerNavigationPreloadStateError::InvalidState);
};
let (version_id, previous_state) = {
let Some(version_id) = state
.registrations
.get(&registration_id)
.and_then(|registration| navigation_preload_version_id(&state, registration))
else {
return Err(ServiceWorkerNavigationPreloadStateError::InvalidState);
};
let previous_state = {
let Some(registration) = state.registrations.get_mut(&registration_id) else {
return Err(ServiceWorkerNavigationPreloadStateError::InvalidState);
};
let Some(version_id) = registration.active_version_id else {
return Err(ServiceWorkerNavigationPreloadStateError::InvalidState);
};
let previous_state = registration.navigation_preload_state.clone();
update(&mut registration.navigation_preload_state);
(version_id, previous_state)
previous_state
};
if self
.store_registration_resources_locked(&state, registration_id, version_id)
@@ -119,6 +119,79 @@ fn navigation_preload_state_defaults_and_mutation_requires_active_worker() {
&ServiceWorkerNavigationPreloadState::default()
);
}
#[test]
fn navigation_preload_state_survives_initial_activation() {
let service = new_service_worker_runtime_service();
let registration_id = ServiceWorkerRegistrationId(1);
let version_id = ServiceWorkerVersionId(1);
let scope_url = url("https://example.test/app/");
insert_inactive_registration(
&service,
registration_id,
version_id,
url("https://example.test/app/worker.js"),
scope_url.clone(),
);
make_version_persistable(&service, version_id);
{
let mut state = service.inner.state.lock();
let registration = state.registrations.get_mut(&registration_id).unwrap();
registration.installing_version_id = None;
registration.waiting_version_id = Some(version_id);
state.versions.get_mut(&version_id).unwrap().lifecycle_state =
ServiceWorkerVersionLifecycleState::Installed;
}
assert_eq!(
service.set_navigation_preload_enabled_for_scope(&scope_url, true),
Err(ServiceWorkerNavigationPreloadStateError::InvalidState),
"an installed worker that has not started activation is still inactive"
);
{
let mut state = service.inner.state.lock();
let version = state.versions.get_mut(&version_id).unwrap();
version.lifecycle_state = ServiceWorkerVersionLifecycleState::Activating;
version.in_flight_event_count = 1;
}
assert_eq!(
service.navigation_preload_state_for_scope(&scope_url),
Some(ServiceWorkerNavigationPreloadState::default())
);
service
.set_navigation_preload_enabled_for_scope(&scope_url, true)
.expect("enable must work during the initial activate event");
service
.set_navigation_preload_header_value_for_scope(&scope_url, "activate-preload".to_owned())
.expect("setHeaderValue must work during the initial activate event");
let expected = ServiceWorkerNavigationPreloadState {
enabled: true,
header_value: "activate-preload".to_owned(),
};
assert_eq!(
service.navigation_preload_state_for_scope(&scope_url),
Some(expected.clone())
);
let run = exact_version_run(&service, version_id);
service.finish_lifecycle_event_completed(ServiceWorkerLifecycleCompletion {
event_id: ServiceWorkerEventId(1),
owner: test_run_owner(version_id, &run),
kind: ServiceWorkerLifecycleEventKind::Activate,
result: Ok(()),
});
assert_eq!(
service.navigation_preload_state_for_scope(&scope_url),
Some(expected.clone())
);
let stored = service
.inner
.resource_store
.lock()
.registrations()
.into_iter()
.find(|registration| registration.scope_url == scope_url)
.expect("activation must persist the navigation preload state");
assert_eq!(stored.navigation_preload_state, expected);
}
#[test]
fn navigation_preload_state_updates_active_registration_and_store() {
let service = new_service_worker_runtime_service();