diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index ec6b3d939..118ae24c0 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -1762,6 +1762,23 @@ pub fn preloadScriptHint(self: *Frame, href: []const u8) void { self._script_manager.preloadScript(resolved) catch {}; } +// start prefetching +pub fn preloadModuleHint(self: *Frame, href: []const u8) void { + if (self.isGoingAway() or self._parse_mode == .fragment) { + return; + } + + // The url becomes the imported_modules key, which must outlive the fetch + // so it lives on the frame arena + const resolved = URL.resolve(self.arena, self.base(), href, .{ .encoding = self.charset }) catch return; + if (!std.ascii.startsWithIgnoreCase(resolved, "http:") and !std.ascii.startsWithIgnoreCase(resolved, "https:")) { + // data:/blob: are synthesized locally — no round-trip to hide. + return; + } + + self._script_manager.base.preloadModuleHint(resolved, self.url) catch {}; +} + // Synchronously fetch and parse an external ``. // href is passed in as an optimization since the [currently] only callsite has // it, so why look it up again? diff --git a/src/browser/ScriptManagerBase.zig b/src/browser/ScriptManagerBase.zig index f917ced60..a1969501f 100644 --- a/src/browser/ScriptManagerBase.zig +++ b/src/browser/ScriptManagerBase.zig @@ -217,10 +217,19 @@ pub fn resolveSpecifier(self: *ScriptManagerBase, arena: Allocator, base: [:0]co return error.SpecifierResolutionFailed; } -pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []const u8) !void { +const PreloadOpts = struct { + hint: bool = false, +}; +pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []const u8, opts: PreloadOpts) !void { const gop = try self.imported_modules.getOrPut(self.allocator, url); if (gop.found_existing) { - gop.value_ptr.waiters += 1; + if (gop.value_ptr.hint) { + // A never calls waitForImport, so the + // first real import adopts its waiter slot rather than adding one. + gop.value_ptr.hint = false; + } else { + gop.value_ptr.waiters += 1; + } return; } errdefer _ = self.imported_modules.remove(url); @@ -239,7 +248,7 @@ pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []co .extra = .import, }; - gop.value_ptr.* = ImportedModule{}; + gop.value_ptr.* = .{ .state = .{ .loading = script }, .hint = opts.hint }; if (comptime IS_DEBUG) { var ls: js.Local.Scope = undefined; @@ -284,6 +293,15 @@ pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []co }; } +// — start fetching a module before import +// resolution discovers it. +pub fn preloadModuleHint(self: *ScriptManagerBase, url: [:0]const u8, referrer: []const u8) !void { + if (self.imported_modules.contains(url)) { + return; + } + try self.preloadImport(url, referrer, .{ .hint = true }); +} + pub fn waitForImport(self: *ScriptManagerBase, url: [:0]const u8) !ModuleSource { const entry = self.imported_modules.getEntry(url) orelse { // It shouldn't be possible for v8 to ask for a module that we didn't @@ -324,7 +342,57 @@ pub fn waitForImport(self: *ScriptManagerBase, url: [:0]const u8) !ModuleSource } } +pub fn releaseImport(self: *ScriptManagerBase, url: [:0]const u8) void { + const entry = self.imported_modules.getEntry(url) orelse { + return; + }; + if (entry.value_ptr.waiters > 1) { + entry.value_ptr.waiters -= 1; + return; + } + switch (entry.value_ptr.state) { + .done => |script| script.deinit(), + .loading, .err => return, + } + self.imported_modules.removeByPtr(entry.key_ptr); +} + pub fn getAsyncImport(self: *ScriptManagerBase, url: [:0]const u8, cb: ImportAsync.Callback, cb_data: *anyopaque, referrer: []const u8) !void { + // A hint may already be fetching/fetched this module + if (self.imported_modules.getEntry(url)) |entry| { + if (entry.value_ptr.hint) { + switch (entry.value_ptr.state) { + .loading => |script| { + // fetch is in flight, take the script and turn it into + // our normal getAsyncImport flow (e.g. what we do at the + // end of this file as-if imported_modules didn't have this script) + if (comptime IS_DEBUG) { + log.debug(.http, "script adopt", .{ .url = url, .ctx = "dynamic module", .state = "loading" }); + } + script.extra = .{ .import_async = .{ .callback = cb, .data = cb_data } }; + self.imported_modules.removeByPtr(entry.key_ptr); + return; + }, + .done => |script| { + // fetch is complete; deliver through the normal + // ready_scripts flow. evaluate() runs it now, or — if an + // evaluation window is open — evaluate_pending runs it + // when that window closes. + if (comptime IS_DEBUG) { + log.debug(.http, "script adopt", .{ .url = url, .ctx = "dynamic module", .state = "done" }); + } + script.extra = .{ .import_async = .{ .callback = cb, .data = cb_data } }; + self.imported_modules.removeByPtr(entry.key_ptr); + self.ready_scripts.append(&script.node); + self.evaluate(); + return; + }, + // The hint's fetch failed; give the import its own attempt. + .err => {}, + } + } + } + const arena = try self.acquireArena(.large, "SM.getAsyncImport"); errdefer self.releaseArena(arena); @@ -890,12 +958,17 @@ pub const ModuleSource = struct { pub const ImportedModule = struct { waiters: u16 = 1, - state: State = .loading, + // Created by a hint and not yet claimed by a real + // import. While set, the single waiter slot belongs to the hint, which + // will never collect it (see preloadModuleHint). A dynamic import may + // adopt a hint entry outright (see getAsyncImport). + hint: bool = false, + state: State, buffer: std.ArrayList(u8) = .{}, pub const State = union(enum) { err, - loading, + loading: *Script, done: *Script, }; }; diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index af39fe0a8..572e12741 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -516,7 +516,7 @@ fn postCompileModule(self: *Context, mod: js.Module, url: [:0]const u8, local: * const owned_specifier = try self.arena.dupeZ(u8, normalized_specifier); nested_gop.key_ptr.* = owned_specifier; nested_gop.value_ptr.* = .{}; - try script_manager.preloadImport(owned_specifier, url); + try script_manager.preloadImport(owned_specifier, url, .{}); } else if (nested_gop.value_ptr.module == null) { // Entry exists but module failed to compile previously. // The imported_modules entry may have been consumed, so @@ -524,7 +524,7 @@ fn postCompileModule(self: *Context, mod: js.Module, url: [:0]const u8, local: * // Key was stored via dupeZ so it has a sentinel in memory. const key = nested_gop.key_ptr.*; const key_z: [:0]const u8 = key.ptr[0..key.len :0]; - try script_manager.preloadImport(key_z, url); + try script_manager.preloadImport(key_z, url, .{}); } } } @@ -733,6 +733,11 @@ fn _resolveModuleCallback(self: *Context, referrer: js.Module, specifier: [:0]co const entry = self.module_cache.getPtr(normalized_specifier).?; if (entry.module) |m| { + // This import registered a waiter via preloadImport when it was discovered + // but the compiled module is already cached so we don't have to call + // waitForImport. Release our waiter so we no longer hold on waiter on + // the resource. + self.script_manager.releaseImport(normalized_specifier); return local.toLocal(m).handle; } @@ -740,7 +745,7 @@ fn _resolveModuleCallback(self: *Context, referrer: js.Module, specifier: [:0]co error.UnknownModule => blk: { // Module is in cache but was consumed from imported_modules // (e.g., by a previous failed resolution). Re-preload and retry. - try self.script_manager.preloadImport(normalized_specifier, referrer_path); + try self.script_manager.preloadImport(normalized_specifier, referrer_path, .{}); break :blk try self.script_manager.waitForImport(normalized_specifier); }, else => return err, diff --git a/src/browser/tests/element/html/link.html b/src/browser/tests/element/html/link.html index d3f2faa0f..754fe27e7 100644 --- a/src/browser/tests/element/html/link.html +++ b/src/browser/tests/element/html/link.html @@ -120,11 +120,15 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/html/script/modulepreload.js b/src/browser/tests/element/html/script/modulepreload.js new file mode 100644 index 000000000..fed413f5b --- /dev/null +++ b/src/browser/tests/element/html/script/modulepreload.js @@ -0,0 +1 @@ +export const val = 'preloaded-module'; diff --git a/src/browser/tests/element/html/script/modulepreload_dynamic.js b/src/browser/tests/element/html/script/modulepreload_dynamic.js new file mode 100644 index 000000000..d8c3e9858 --- /dev/null +++ b/src/browser/tests/element/html/script/modulepreload_dynamic.js @@ -0,0 +1 @@ +export const dval = 'dynamic-preloaded'; diff --git a/src/browser/tests/element/html/script/modulepreload_dynamic2.js b/src/browser/tests/element/html/script/modulepreload_dynamic2.js new file mode 100644 index 000000000..e9983ba8f --- /dev/null +++ b/src/browser/tests/element/html/script/modulepreload_dynamic2.js @@ -0,0 +1 @@ +export const dval = 'dynamic-preloaded-2'; diff --git a/src/browser/tests/element/html/script/modulepreload_unused.js b/src/browser/tests/element/html/script/modulepreload_unused.js new file mode 100644 index 000000000..3535633b2 --- /dev/null +++ b/src/browser/tests/element/html/script/modulepreload_unused.js @@ -0,0 +1,5 @@ +// Nothing imports this module, so it must never be evaluated. If it runs, the +// flag trips the assertion in modulepreload.html (and this fail() fires +// directly). +window.modulepreload_unused_ran = true; +testing.fail('an unconsumed must not evaluate'); diff --git a/src/browser/tests/page/module.html b/src/browser/tests/page/module.html index bb820332b..bba90ebe5 100644 --- a/src/browser/tests/page/module.html +++ b/src/browser/tests/page/module.html @@ -58,6 +58,18 @@ testing.expectEqual('a', getFromA()); + +