From ac8fb0e1f5672602c802bbb21ec6fd37b73aca7e Mon Sep 17 00:00:00 2001 From: chloe caruso Date: Tue, 25 Feb 2025 22:04:10 -0800 Subject: [PATCH] fix(bundler): bundling invalid html / export star as / react refresh fixes (#17685) --- src/HTMLScanner.zig | 61 ++--- src/bake/DevServer.zig | 41 ++-- src/bake/client/overlay.css | 2 + src/bake/hmr-module.ts | 50 +++- src/bundler/bundle_v2.zig | 158 +++++++++---- src/js_parser.zig | 34 ++- src/js_printer.zig | 2 +- test/bake/bake-harness.ts | 77 +----- test/bake/dev-and-prod.test.ts | 107 +++++++++ test/bake/dev/bundle.test.ts | 196 +--------------- test/bake/dev/esm.test.ts | 48 +++- test/bake/dev/react-spa.test.ts | 399 +++++++++++++++++++++++++++++++- 12 files changed, 784 insertions(+), 391 deletions(-) diff --git a/src/HTMLScanner.zig b/src/HTMLScanner.zig index 4a69da3574..ad93c49d0b 100644 --- a/src/HTMLScanner.zig +++ b/src/HTMLScanner.zig @@ -79,7 +79,11 @@ pub fn scan(this: *HTMLScanner, input: []const u8) !void { try processor.run(this, input); } -pub fn HTMLProcessor(comptime T: type, comptime visit_head_and_body: bool) type { +pub fn HTMLProcessor( + comptime T: type, + /// If the visitor should visit html, head, body + comptime visit_document_tags: bool, +) type { return struct { const TagHandler = struct { /// CSS selector to match elements @@ -151,12 +155,6 @@ pub fn HTMLProcessor(comptime T: type, comptime visit_head_and_body: bool) type .url_attribute = "href", .kind = .url, }, - // Catch-all for other links with href - .{ - .selector = "link:not([rel~='stylesheet']):not([rel~='modulepreload']):not([rel~='manifest']):not([rel~='icon']):not([rel~='apple-touch-icon'])[href]", - .url_attribute = "href", - .kind = .url, - }, // Images with src .{ .selector = "img[src]", @@ -231,7 +229,7 @@ pub fn HTMLProcessor(comptime T: type, comptime visit_head_and_body: bool) type var builder = lol.HTMLRewriter.Builder.init(); defer builder.deinit(); - var selectors: std.BoundedArray(*lol.HTMLSelector, tag_handlers.len + if (visit_head_and_body) 2 else 0) = .{}; + var selectors: std.BoundedArray(*lol.HTMLSelector, tag_handlers.len + if (visit_document_tags) 3 else 0) = .{}; defer for (selectors.slice()) |selector| { selector.deinit(); }; @@ -254,36 +252,23 @@ pub fn HTMLProcessor(comptime T: type, comptime visit_head_and_body: bool) type ); } - if (visit_head_and_body) { - const head_selector = try lol.HTMLSelector.parse("head"); - selectors.appendAssumeCapacity(head_selector); - try builder.addElementContentHandlers( - head_selector, - T, - T.onHeadTag, - this, - void, - null, - null, - void, - null, - null, - ); - - const body_selector = try lol.HTMLSelector.parse("body"); - selectors.appendAssumeCapacity(body_selector); - try builder.addElementContentHandlers( - body_selector, - T, - T.onBodyTag, - this, - void, - null, - null, - void, - null, - null, - ); + if (visit_document_tags) { + inline for (.{ "body", "head", "html" }, &.{ T.onBodyTag, T.onHeadTag, T.onHtmlTag }) |tag, cb| { + const head_selector = try lol.HTMLSelector.parse(tag); + selectors.appendAssumeCapacity(head_selector); + try builder.addElementContentHandlers( + head_selector, + T, + cb, + this, + void, + null, + null, + void, + null, + null, + ); + } } const memory_settings = lol.MemorySettings{ diff --git a/src/bake/DevServer.zig b/src/bake/DevServer.zig index ece4835cd1..78a268397c 100644 --- a/src/bake/DevServer.zig +++ b/src/bake/DevServer.zig @@ -253,10 +253,7 @@ pub const RouteBundle = struct { bundled_file: IncrementalGraph(.client).FileIndex, /// Invalidated when the HTML file is modified, but not it's imports. /// The style tag is injected here. - head_end_tag_index: ByteOffset.Optional, - /// Invalidated when the HTML file is modified, but not it's imports. - /// The script tag is injected here. - body_end_tag_index: ByteOffset.Optional, + script_injection_offset: ByteOffset.Optional, /// The HTML file bundled, from the bundler. bundled_html_text: ?[]const u8, /// Derived from `bundled_html_text` + `client_script_generation` @@ -1421,12 +1418,11 @@ fn generateHTMLPayload(dev: *DevServer, route_bundle_index: RouteBundle.Index, r assert(route_bundle.server_state == .loaded); // if not loaded, following values wont be initialized assert(html.html_bundle.dev_server_id.unwrap() == route_bundle_index); assert(html.cached_response == null); - const head_end_tag_index = (html.head_end_tag_index.unwrap() orelse unreachable).get(); - const body_end_tag_index = (html.body_end_tag_index.unwrap() orelse unreachable).get(); + const script_injection_offset = (html.script_injection_offset.unwrap() orelse unreachable).get(); const bundled_html = html.bundled_html_text orelse unreachable; - // The bundler records two offsets in development mode, splitting the HTML - // file into three chunks. DevServer is able to insert style/script tags + // The bundler records an offsets in development mode, splitting the HTML + // file into two chunks. DevServer is able to insert style/script tags // using the information available in IncrementalGraph. This approach // allows downstream files to update without re-bundling the HTML file. // @@ -1434,14 +1430,13 @@ fn generateHTMLPayload(dev: *DevServer, route_bundle_index: RouteBundle.Index, r // // // Single Page Web App - // {head_end_tag_index} + // {script_injection_offset} // //
- // {body_end_tag_index} + // // - const before_head_end = bundled_html[0..head_end_tag_index]; - const before_body_end = bundled_html[head_end_tag_index..body_end_tag_index]; - const after_body_end = bundled_html[body_end_tag_index..]; + const before_head_end = bundled_html[0..script_injection_offset]; + const after_head_end = bundled_html[script_injection_offset..]; var display_name = bun.strings.withoutSuffixComptime(bun.path.basename(html.html_bundle.html_bundle.path), ".html"); // TODO: function for URL safe chars @@ -1487,9 +1482,8 @@ fn generateHTMLPayload(dev: *DevServer, route_bundle_index: RouteBundle.Index, r array.appendSliceAssumeCapacity(&std.fmt.bytesToHex(std.mem.asBytes(&route_bundle.client_script_generation), .lower)); array.appendSliceAssumeCapacity(".js\">"); - array.appendSliceAssumeCapacity(before_body_end); // DevServer used to put the script tag before the body end, but to match the regular bundler it does not do this. - array.appendSliceAssumeCapacity(after_body_end); + array.appendSliceAssumeCapacity(after_head_end); assert(array.items.len == array.capacity); // incorrect memory allocation size return array.items; } @@ -2197,8 +2191,7 @@ pub fn finalizeBundle( dev.allocation_scope.assertOwned(compile_result.code); html.bundled_html_text = compile_result.code; - html.head_end_tag_index = .init(compile_result.offsets.head_end_tag); - html.body_end_tag_index = .init(compile_result.offsets.body_end_tag); + html.script_injection_offset = .init(compile_result.script_injection_offset); chunk.entry_point.entry_point_id = @intCast(route_bundle_index.get()); } @@ -2802,8 +2795,7 @@ fn getOrPutRouteBundle(dev: *DevServer, route: RouteBundle.UnresolvedIndex) !Rou break :brk .{ .html = .{ .html_bundle = html, .bundled_file = incremental_graph_index, - .head_end_tag_index = .none, - .body_end_tag_index = .none, + .script_injection_offset = .none, .cached_response = null, .bundled_html_text = null, } }; @@ -4660,6 +4652,17 @@ pub fn IncrementalGraph(side: bake.Side) type { } j.pushStatic(","); const quoted_slice = map.quotedContents(); + if (quoted_slice.len == 0) { + bun.debugAssert(false); // vlq without source contents! + const ptr: bun.StringPointer = .{ + .offset = @intCast(j.len + ",\"".len), + .length = 0, + }; + j.pushStatic(",\"// Did not have source contents for this file.\n// This is a bug in Bun's bundler and should be reported with a reproduction.\""); + file_paths.appendAssumeCapacity(paths[file_index.get()]); + source_contents.appendAssumeCapacity(ptr); + continue; + } // Store the location of the source file. Since it is going // to be stored regardless for use by the served source map. // These 8 bytes per file allow remapping sources without diff --git a/src/bake/client/overlay.css b/src/bake/client/overlay.css index f5b870f37c..cbcb18bd86 100644 --- a/src/bake/client/overlay.css +++ b/src/bake/client/overlay.css @@ -12,6 +12,8 @@ } .root { + all: initial; + /* TODO: revive light theme error modal */ /* color-scheme: light dark; --modal-bg: light-dark(#efefef, #202020); diff --git a/src/bake/hmr-module.ts b/src/bake/hmr-module.ts index f82c238f3e..778494ddb3 100644 --- a/src/bake/hmr-module.ts +++ b/src/bake/hmr-module.ts @@ -34,6 +34,22 @@ interface DepEntry { _expectedImports: string[] | undefined; } +/** See `runtime.js`'s `__toCommonJS`. This omits the cache. */ +export var toCommonJSUncached = /* @__PURE__ */ from => { + var desc, + entry = Object.defineProperty({}, "__esModule", { value: true }); + if ((from && typeof from === "object") || typeof from === "function") + Object.getOwnPropertyNames(from).map( + key => + !Object.prototype.hasOwnProperty.call(entry, key) && + Object.defineProperty(entry, key, { + get: () => from[key], + enumerable: !(desc = Object.getOwnPropertyDescriptor(from, key)) || desc.enumerable, + }), + ); + return entry; +}; + /** * The expression `import(a,b)` is not supported in all browsers, most notably * in Mozilla Firefox. It is lazily evaluated, and will throw a SyntaxError @@ -41,6 +57,8 @@ interface DepEntry { */ let lazyDynamicImportWithOptions; +const kDebugModule = /* @__PURE__ */ Symbol("HotModule"); + /** * This object is passed as the CommonJS "module", but has a bunch of * non-standard properties that are used for implementing hot-module reloading. @@ -52,31 +70,39 @@ export class HotModule { exports: E = {} as E; _state = State.Loading; + /** for MJS <-> CJS interop. this stores the other module exports */ _ext_exports = undefined; - __esModule = false; + _esm = false; _import_meta: ImportMeta | undefined = undefined; _cached_failure: any = undefined; - // modules that import THIS module + /** modules that import THIS module */ _deps: Map = new Map(); + /** from `import.meta.hot.dispose` */ _onDispose: HotDisposeFunction[] | undefined = undefined; constructor(id: Id) { this.id = id; + + if (IS_BUN_DEVELOPMENT) { + Object.defineProperty(this.exports, kDebugModule, { + value: this, + enumerable: false, + }); + } } require(id: Id, onReload?: ExportsCallbackFunction) { const mod = loadModule(id, LoadModuleType.SyncUserDynamic) as HotModule; mod._deps.set(this, onReload ? { _callback: onReload, _expectedImports: undefined } : undefined); - return mod.exports; + const { exports, _esm } = mod; + return _esm ? (mod._ext_exports ??= runtimeHelpers.__toCommonJS(exports)) : exports; } async importStmt(id: Id, onReload?: ExportsCallbackFunction, expectedImports?: string[]) { const mod = await (loadModule(id, LoadModuleType.AsyncAssertPresent) as Promise); mod._deps.set(this, onReload ? { _callback: onReload, _expectedImports: expectedImports } : undefined); - const { exports, __esModule } = mod; - const object = __esModule - ? exports - : (mod._ext_exports ??= { ...(typeof exports === "object" && exports), default: exports }); + const { exports, _esm } = mod; + const object = _esm ? exports : (mod._ext_exports ??= runtimeHelpers.__toESM(exports)); // if (expectedImports && mod._state === State.Ready) { // for (const key of expectedImports) { @@ -107,8 +133,8 @@ export class HotModule { const mod = await (loadModule(specifier, LoadModuleType.AsyncUserDynamic) as Promise); // insert into the map if not present mod._deps.set(this, mod._deps.get(this)); - const { exports, __esModule } = mod; - return __esModule ? exports : (mod._ext_exports ??= { ...exports, default: exports }); + const { exports, _esm } = mod; + return _esm ? exports : (mod._ext_exports ??= { ...exports, default: exports }); } importMeta() { @@ -355,7 +381,7 @@ export async function replaceModules(modules: any) { { const runtime = new HotModule("bun:wrap"); runtime.exports = runtimeHelpers; - runtime.__esModule = true; + runtime._esm = true; registry.set("bun:wrap", runtime); } @@ -366,7 +392,7 @@ export let onServerSideReload: (() => Promise) | null = null; if (side === "server") { const server_module = new HotModule("bun:bake/server"); - server_module.__esModule = true; + server_module._esm = true; server_module.exports = { serverManifest, ssrManifest, actionManifest: null }; registry.set(server_module.id, server_module); } @@ -379,7 +405,7 @@ if (side === "client") { } const server_module = new HotModule("bun:bake/client"); - server_module.__esModule = true; + server_module._esm = true; server_module.exports = { onServerSideReload: async cb => { onServerSideReload = cb; diff --git a/src/bundler/bundle_v2.zig b/src/bundler/bundle_v2.zig index 7340f14ead..50e635baf7 100644 --- a/src/bundler/bundle_v2.zig +++ b/src/bundler/bundle_v2.zig @@ -10611,8 +10611,12 @@ pub const LinkerContext = struct { chunks: []Chunk, minify_whitespace: bool, output: std.ArrayList(u8), - head_end_tag_index: u32 = 0, - body_end_tag_index: u32 = 0, + end_tag_indices: struct { + head: ?u32 = 0, + body: ?u32 = 0, + html: ?u32 = 0, + }, + added_head_tags: bool, pub fn onWriteHTML(this: *@This(), bytes: []const u8) void { this.output.appendSlice(bytes) catch bun.outOfMemory(); @@ -10672,46 +10676,76 @@ pub const LinkerContext = struct { } pub fn onHeadTag(this: *@This(), element: *lol.Element) bool { - var html_appender = std.heap.stackFallback(256, bun.default_allocator); - const allocator = html_appender.get(); - - if (this.linker.dev_server == null) { - // Put CSS before JS to reduce changes of flash of unstyled content - if (this.chunk.getCSSChunkForHTML(this.chunks)) |css_chunk| { - const link_tag = std.fmt.allocPrintZ(allocator, "", .{css_chunk.unique_key}) catch bun.outOfMemory(); - defer allocator.free(link_tag); - element.append(link_tag, true) catch bun.outOfMemory(); - } - - if (this.chunk.getJSChunkForHTML(this.chunks)) |js_chunk| { - // type="module" scripts do not block rendering, so it is okay to put them in head - const script = std.fmt.allocPrintZ(allocator, "", .{js_chunk.unique_key}) catch bun.outOfMemory(); - defer allocator.free(script); - element.append(script, true) catch bun.outOfMemory(); - } - } else { - element.onEndTag(endHeadTagHandler, this) catch return true; - } + element.onEndTag(endHeadTagHandler, this) catch return true; + return false; + } + pub fn onHtmlTag(this: *@This(), element: *lol.Element) bool { + element.onEndTag(endHtmlTagHandler, this) catch return true; return false; } pub fn onBodyTag(this: *@This(), element: *lol.Element) bool { - if (this.linker.dev_server != null) { - element.onEndTag(endBodyTagHandler, this) catch return true; - } + element.onEndTag(endBodyTagHandler, this) catch return true; return false; } - fn endHeadTagHandler(_: *lol.EndTag, opaque_this: ?*anyopaque) callconv(.C) lol.Directive { + /// This is called for head, body, and html; whichever ends up coming first. + fn addHeadTags(this: *@This(), endTag: *lol.EndTag) !void { + if (this.added_head_tags) return; + this.added_head_tags = true; + + var html_appender = std.heap.stackFallback(256, bun.default_allocator); + const allocator = html_appender.get(); + const slices = this.getHeadTags(allocator); + defer for (slices.slice()) |slice| + allocator.free(slice); + for (slices.slice()) |slice| + try endTag.before(slice, true); + } + + fn getHeadTags(this: *@This(), allocator: std.mem.Allocator) std.BoundedArray([]const u8, 2) { + var array: std.BoundedArray([]const u8, 2) = .{}; + // Put CSS before JS to reduce changes of flash of unstyled content + if (this.chunk.getCSSChunkForHTML(this.chunks)) |css_chunk| { + const link_tag = std.fmt.allocPrintZ(allocator, "", .{css_chunk.unique_key}) catch bun.outOfMemory(); + array.appendAssumeCapacity(link_tag); + } + if (this.chunk.getJSChunkForHTML(this.chunks)) |js_chunk| { + // type="module" scripts do not block rendering, so it is okay to put them in head + const script = std.fmt.allocPrintZ(allocator, "", .{js_chunk.unique_key}) catch bun.outOfMemory(); + array.appendAssumeCapacity(script); + } + return array; + } + + fn endHeadTagHandler(end: *lol.EndTag, opaque_this: ?*anyopaque) callconv(.C) lol.Directive { const this: *@This() = @alignCast(@ptrCast(opaque_this.?)); - this.head_end_tag_index = @intCast(this.output.items.len); + if (this.linker.dev_server == null) { + this.addHeadTags(end) catch return .stop; + } else { + this.end_tag_indices.head = @intCast(this.output.items.len); + } return .@"continue"; } - fn endBodyTagHandler(_: *lol.EndTag, opaque_this: ?*anyopaque) callconv(.C) lol.Directive { + fn endBodyTagHandler(end: *lol.EndTag, opaque_this: ?*anyopaque) callconv(.C) lol.Directive { const this: *@This() = @alignCast(@ptrCast(opaque_this.?)); - this.body_end_tag_index = @intCast(this.output.items.len); + if (this.linker.dev_server == null) { + this.addHeadTags(end) catch return .stop; + } else { + this.end_tag_indices.body = @intCast(this.output.items.len); + } + return .@"continue"; + } + + fn endHtmlTagHandler(end: *lol.EndTag, opaque_this: ?*anyopaque) callconv(.C) lol.Directive { + const this: *@This() = @alignCast(@ptrCast(opaque_this.?)); + if (this.linker.dev_server == null) { + this.addHeadTags(end) catch return .stop; + } else { + this.end_tag_indices.html = @intCast(this.output.items.len); + } return .@"continue"; } }; @@ -10731,6 +10765,12 @@ pub const LinkerContext = struct { .chunks = chunks, .output = std.ArrayList(u8).init(output_allocator), .current_import_record_index = 0, + .end_tag_indices = .{ + .html = null, + .body = null, + .head = null, + }, + .added_head_tags = false, }; HTMLScanner.HTMLProcessor(HTMLLoader, true).run( @@ -10738,16 +10778,41 @@ pub const LinkerContext = struct { sources[chunk.entry_point.source_index].contents, ) catch bun.outOfMemory(); - return .{ - .html = .{ - .code = html_loader.output.items, - .source_index = chunk.entry_point.source_index, - .offsets = .{ - .head_end_tag = html_loader.head_end_tag_index, - .body_end_tag = html_loader.body_end_tag_index, - }, - }, + // There are some cases where invalid HTML will make it so is + // never emitted, even if the literal text DOES appear. These cases are + // along the lines of having a self-closing tag for a non-self closing + // element. In this case, head_end_tag_index will be 0, and a simple + // search through the page is done to find the "" + // See https://github.com/oven-sh/bun/issues/17554 + const script_injection_offset: u32 = if (c.dev_server != null) brk: { + if (html_loader.end_tag_indices.head) |head| + break :brk head; + if (bun.strings.indexOf(html_loader.output.items, "")) |head| + break :brk @intCast(head); + if (html_loader.end_tag_indices.body) |body| + break :brk body; + if (html_loader.end_tag_indices.html) |html| + break :brk html; + break :brk @intCast(html_loader.output.items.len); // inject at end of file. + } else brk: { + if (!html_loader.added_head_tags) { + @branchHint(.cold); // this is if the document is missing all head, body, and html elements. + var html_appender = std.heap.stackFallback(256, bun.default_allocator); + const allocator = html_appender.get(); + const slices = html_loader.getHeadTags(allocator); + for (slices.slice()) |slice| { + html_loader.output.appendSlice(slice) catch bun.outOfMemory(); + allocator.free(slice); + } + } + break :brk if (Environment.isDebug) undefined else 0; // value is ignored. fail loud if hit in debug }; + + return .{ .html = .{ + .code = html_loader.output.items, + .source_index = chunk.entry_point.source_index, + .script_injection_offset = script_injection_offset, + } }; } fn postProcessHTMLChunk(ctx: GenerateChunkCtx, worker: *ThreadPool.Worker, chunk: *Chunk) !void { @@ -12987,8 +13052,12 @@ pub const LinkerContext = struct { // - export wrapping is already done. // - import wrapping needs to know resolved paths // - one part range per file (ensured by another special cased code path in findAllImportedPartsInJSOrder) - if (c.options.output_format == .internal_bake_dev) { - bun.assert(!part_range.source_index.isRuntime()); // embedded in HMR runtime + if (c.options.output_format == .internal_bake_dev) brk: { + if (part_range.source_index.isRuntime()) { + @branchHint(.cold); + bun.debugAssert(c.dev_server == null); + break :brk; // this is from `bun build --format=internal_bake_dev` + } // add a marker for the client runtime to tell that this is an ES module if (ast.exports_kind == .esm) { @@ -12996,7 +13065,7 @@ pub const LinkerContext = struct { .value = Expr.assign( Expr.init(E.Dot, .{ .target = Expr.initIdentifier(ast.module_ref, Loc.Empty), - .name = "__esModule", + .name = "_esm", .name_loc = Loc.Empty, }, Loc.Empty), Expr.init(E.Boolean, .{ .value = true }, Loc.Empty), @@ -16669,12 +16738,7 @@ pub const CompileResult = union(enum) { source_index: Index.Int, code: []const u8, /// Offsets are used for DevServer to inject resources without re-bundling - offsets: struct { - /// The index of the "<" byte of "" - head_end_tag: u32, - /// The index of the "<" byte of "" - body_end_tag: u32, - }, + script_injection_offset: u32, }, pub const empty = CompileResult{ diff --git a/src/js_parser.zig b/src/js_parser.zig index 10da0ccd0d..1fb03151de 100644 --- a/src/js_parser.zig +++ b/src/js_parser.zig @@ -23307,6 +23307,14 @@ fn NewParser_( .user_hooks = .{}, }; + // TODO(paperclover): fix the renamer bug. this bug + // theoretically affects all usages of temp refs, but i cannot + // find another example of it breaking (like with `using`) + p.declared_symbols.append(p.allocator, .{ + .is_top_level = true, + .ref = ctx_storage.*.?.signature_cb, + }) catch bun.outOfMemory(); + break :init &(ctx_storage.*.?); }; @@ -23327,10 +23335,13 @@ fn NewParser_( inline .e_identifier, .e_import_identifier, .e_commonjs_export_identifier, - => |id| { + => |id, tag| { const gop = ctx.user_hooks.getOrPut(p.allocator, id.ref) catch bun.outOfMemory(); if (!gop.found_existing) { - gop.value_ptr.* = Expr.initIdentifier(id.ref, logger.Loc.Empty); + gop.value_ptr.* = .{ + .data = @unionInit(Expr.Data, @tagName(tag), id), + .loc = .Empty, + }; } }, else => {}, @@ -24359,10 +24370,21 @@ pub const ConvertESMExportsForHmr = struct { null, stmt.loc, ); - try ctx.export_star_props.append(p.allocator, .{ - .kind = .spread, - .value = Expr.initIdentifier(namespace_ref, stmt.loc), - }); + + if (st.alias) |alias| { + // 'export * as ns from' creates one named property. + try ctx.export_props.append(p.allocator, .{ + .key = Expr.init(E.String, .{ .data = alias.original_name }, stmt.loc), + .value = Expr.initIdentifier(namespace_ref, stmt.loc), + }); + } else { + // 'export * from' creates a spread, hoisted at the top. + // TODO: for perfect HMR, this likely needs more instrumentation + try ctx.export_star_props.append(p.allocator, .{ + .kind = .spread, + .value = Expr.initIdentifier(namespace_ref, stmt.loc), + }); + } return; }, // De-duplicate import statements. It is okay to disregard diff --git a/src/js_printer.zig b/src/js_printer.zig index a9fcaa8640..305baf3a88 100644 --- a/src/js_printer.zig +++ b/src/js_printer.zig @@ -6001,7 +6001,7 @@ pub fn printWithWriterAndPlatform( imported_module_ids_list = printer.imported_module_ids; } - if (opts.module_type == .internal_bake_dev) { + if (opts.module_type == .internal_bake_dev and !source.index.isRuntime()) { printer.indent(); printer.printIndent(); if (!ast.top_level_await_keyword.isEmpty()) { diff --git a/test/bake/bake-harness.ts b/test/bake/bake-harness.ts index 517cfbc22f..ff10a3d4e0 100644 --- a/test/bake/bake-harness.ts +++ b/test/bake/bake-harness.ts @@ -56,76 +56,6 @@ export const reactRefreshStub = { `, }; -/** To test react refresh's registration system */ -export const reactAndRefreshStub = { - "node_modules/react-refresh/runtime.js": ` - exports.performReactRefresh = () => {}; - exports.injectIntoGlobalHook = () => {}; - exports.register = require("bun-devserver-react-mock").register; - exports.createSignatureFunctionForTransform = require("bun-devserver-react-mock").createSignatureFunctionForTransform; - `, - "node_modules/react/index.js": ` - exports.useState = (y) => [y, x => {}]; - `, - "node_modules/bun-devserver-react-mock/index.js": ` - globalThis.components = new Map(); - globalThis.functionToComponent = new Map(); - exports.expectRegistered = function(fn, filename, exportId) { - const name = filename + ":" + exportId; - try { - if (!components.has(name)) { - for (const [k, v] of components) { - if (v.fn === fn) throw new Error("Component registered under name " + k + " instead of " + name); - } - throw new Error("Component not registered: " + name); - } - if (components.get(name).fn !== fn) throw new Error("Component registered with wrong name: " + name); - } catch (e) { - console.log(components); - throw e; - } - } - exports.hashFromFunction = function(fn) { - if (!keyFromFunction.has(fn)) throw new Error("Function not registered: " + fn); - return keyFromFunction.get(fn).hash; - } - exports.register = function(fn, name) { - if (typeof name !== "string") throw new Error("name must be a string"); - if (typeof fn !== "function") throw new Error("fn must be a function"); - if (components.has(name)) throw new Error("Component already registered: " + name + ". Read its hash from test harness first"); - const entry = functionToComponent.get(fn) ?? { fn, calls: 0, hash: undefined }; - components.set(name, entry); - functionToComponent.set(fn, entry); - } - exports.createSignatureFunctionForTransform = function(fn) { - let entry = null; - return function(fn, hash) { - if (fn !== undefined) { - entry = functionToComponent.get(fn) ?? { fn, calls: 0, hash: undefined }; - functionToComponent.set(fn, entry); - entry.hash = hash; - entry.calls = 0; - return fn; - } else { - if (!entry) throw new Error("Function not registered"); - entry.calls++; - return entry.fn; - } - } - } - `, - "node_modules/react/jsx-dev-runtime.js": ` - export const $$typeof = Symbol.for("react.element"); - export const jsxDEV = (tag, props, key) => ({ - $$typeof, - props, - key, - ref: null, - type: tag, - }); - `, -}; - export function emptyHtmlFile({ styles = [], scripts = [], @@ -369,6 +299,8 @@ export class Dev { storeHotChunks: options.storeHotChunks, hmr: this.nodeEnv === "development", }); + const onPanic = () => client.output.emit("panic"); + this.output.on("panic", onPanic); if (this.nodeEnv === "development") { try { await client.output.waitForLine(hmrClientInitRegex); @@ -380,6 +312,7 @@ export class Dev { } this.connectedClients.add(client); client.on("exit", () => { + this.output.off("panic", onPanic); this.connectedClients.delete(client); }); return client; @@ -945,7 +878,7 @@ function expectProxy(text: Promise, chain: string[], expect: any): any { const fetchExpectProxyHandler: ProxyHandler = { get(target, prop, receiver) { if (Reflect.has(target.expect, prop)) { - return expectProxy(target.text, target.chain.concat(prop), Reflect.get(target.expect, prop, receiver)); + return expectProxy(target.text, target.chain.concat(prop), Reflect.get(target.expect, prop, target.expect)); } return undefined; }, @@ -1127,6 +1060,8 @@ class OutputLineStream extends EventEmitter { constructor(name: string, readable1: ReadableStream, readable2: ReadableStream) { super(); + this.setMaxListeners(10000); // TODO + this.name = name; // @ts-ignore TODO: fix broken type definitions in @types/bun diff --git a/test/bake/dev-and-prod.test.ts b/test/bake/dev-and-prod.test.ts index 3d0ba11261..042c5e597c 100644 --- a/test/bake/dev-and-prod.test.ts +++ b/test/bake/dev-and-prod.test.ts @@ -22,3 +22,110 @@ devAndProductionTest("define config via bunfig.toml", { await c.expectMessage("a=HELLO"); }, }); +devAndProductionTest("invalid html does not crash 1", { + files: { + "public/index.html": ` + + + + Dashboard + + + +
+ + + + `, + "src/app/index.tsx": ` + console.log("hello"); + `, + "src/app/styles.css": ` + body { + background-color: red; + } + `, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.expectMessage("hello"); + await c.style("body").backgroundColor.expect.toBe("red"); + }, +}); +devAndProductionTest("missing all meta tags works fine", { + files: { + "public/index.html": ` + Dashboard + + +
+ + `, + "src/app/index.tsx": ` + console.log("hello"); + `, + "src/app/styles.css": ` + body { + background-color: red; + } + `, + }, + async test(dev) { + await dev.fetch("/").expect.toInclude("root"); + await using c = await dev.client("/"); + await c.expectMessage("hello"); + await c.style("body").backgroundColor.expect.toBe("red"); + }, +}); +devAndProductionTest("inline script and styles appear", { + files: { + "public/index.html": ` + + + + Dashboard + + + + + + + `, + }, + async test(dev) { + await dev.fetch("/").expect.toInclude("hello"); + await dev.fetch("/").expect.not.toInclude("hello 3"); // TODO: + await using c = await dev.client("/"); + await c.expectMessage("hello 3"); + await c.style("body").backgroundColor.expect.toBe("red"); + }, +}); diff --git a/test/bake/dev/bundle.test.ts b/test/bake/dev/bundle.test.ts index 19975c523e..dac52dc123 100644 --- a/test/bake/dev/bundle.test.ts +++ b/test/bake/dev/bundle.test.ts @@ -1,6 +1,6 @@ // Bundle tests are tests concerning bundling bugs that only occur in DevServer. import { expect } from "bun:test"; -import { devTest, emptyHtmlFile, minimalFramework, reactAndRefreshStub, reactRefreshStub } from "../bake-harness"; +import { devTest, emptyHtmlFile, minimalFramework, reactRefreshStub } from "../bake-harness"; devTest("import identifier doesnt get renamed", { framework: minimalFramework, @@ -99,200 +99,6 @@ devTest("importing a file before it is created", { await c.expectMessage("value: 456"); }, }); -// https://github.com/oven-sh/bun/issues/17447 -devTest("react refresh should register and track hook state", { - framework: minimalFramework, - files: { - ...reactAndRefreshStub, - "index.html": emptyHtmlFile({ - styles: [], - scripts: ["index.tsx"], - }), - "index.tsx": ` - import { expectRegistered } from 'bun-devserver-react-mock'; - import App from './App.tsx'; - expectRegistered(App, "App.tsx", "default"); - `, - "App.tsx": ` - export default function App() { - let [a, b] = useState(1); - return
Hello, world!
; - } - `, - }, - async test(dev) { - await using c = await dev.client("/", {}); - const firstHash = await c.reactRefreshComponentHash("App.tsx", "default"); - expect(firstHash).toBeDefined(); - - // hash does not change when hooks stay same - await dev.write( - "App.tsx", - ` - export default function App() { - let [a, b] = useState(1); - return
Hello, world! {a}
; - } - `, - ); - const secondHash = await c.reactRefreshComponentHash("App.tsx", "default"); - expect(secondHash).toEqual(firstHash); - - // hash changes when hooks change - await dev.write( - "App.tsx", - ` - export default function App() { - let [a, b] = useState(2); - return
Hello, world! {a}
; - } - `, - ); - const thirdHash = await c.reactRefreshComponentHash("App.tsx", "default"); - expect(thirdHash).not.toEqual(firstHash); - }, -}); -devTest("react refresh cases", { - framework: minimalFramework, - files: { - ...reactAndRefreshStub, - "index.html": emptyHtmlFile({ - styles: [], - scripts: ["index.tsx"], - }), - "index.tsx": ` - import { expectRegistered } from 'bun-devserver-react-mock'; - - expectRegistered((await import("./default_unnamed")).default, "default_unnamed.tsx", "default"); - expectRegistered((await import("./default_named")).default, "default_named.tsx", "default"); - expectRegistered((await import("./default_arrow")).default, "default_arrow.tsx", "default"); - expectRegistered((await import("./local_var")).LocalVar, "local_var.tsx", "LocalVar"); - expectRegistered((await import("./local_const")).LocalConst, "local_const.tsx", "LocalConst"); - await import("./non_exported"); - - expectRegistered((await import("./default_unnamed_hooks")).default, "default_unnamed_hooks.tsx", "default"); - expectRegistered((await import("./default_named_hooks")).default, "default_named_hooks.tsx", "default"); - expectRegistered((await import("./default_arrow_hooks")).default, "default_arrow_hooks.tsx", "default"); - expectRegistered((await import("./local_var_hooks")).LocalVar, "local_var_hooks.tsx", "LocalVar"); - expectRegistered((await import("./local_const_hooks")).LocalConst, "local_const_hooks.tsx", "LocalConst"); - await import("./non_exported_hooks"); - `, - "default_unnamed.tsx": ` - export default function() { - return
; - } - `, - "default_named.tsx": ` - export default function Hello() { - return
; - } - `, - "default_arrow.tsx": ` - export default () => { - return
; - } - `, - "local_var.tsx": ` - export var LocalVar = () => { - return
; - } - `, - "local_const.tsx": ` - export const LocalConst = () => { - return
; - } - `, - "non_exported.tsx": ` - import { expectRegistered } from 'bun-devserver-react-mock'; - - function NonExportedFunc() { - return
; - } - - const NonExportedVar = () => { - return
; - } - - // Anonymous function with name - const NonExportedAnon = (function MyNamedAnon() { - return
; - }); - - // Anonymous function without name - const NonExportedAnonUnnamed = (function() { - return
; - }); - - expectRegistered(NonExportedFunc, "non_exported.tsx", "NonExportedFunc"); - expectRegistered(NonExportedVar, "non_exported.tsx", "NonExportedVar"); - expectRegistered(NonExportedAnon, "non_exported.tsx", "NonExportedAnon"); - expectRegistered(NonExportedAnonUnnamed, "non_exported.tsx", "NonExportedAnonUnnamed"); - `, - "default_unnamed_hooks.tsx": ` - export default function() { - const [count, setCount] = useState(0); - return
{count}
; - } - `, - "default_named_hooks.tsx": ` - export default function Hello() { - const [count, setCount] = useState(0); - return
{count}
; - } - `, - "default_arrow_hooks.tsx": ` - export default () => { - const [count, setCount] = useState(0); - return
{count}
; - } - `, - "local_var_hooks.tsx": ` - export var LocalVar = () => { - const [count, setCount] = useState(0); - return
{count}
; - } - `, - "local_const_hooks.tsx": ` - export const LocalConst = () => { - const [count, setCount] = useState(0); - return
{count}
; - } - `, - "non_exported_hooks.tsx": ` - import { expectRegistered } from 'bun-devserver-react-mock'; - - function NonExportedFunc() { - const [count, setCount] = useState(0); - return
{count}
; - } - - const NonExportedVar = () => { - const [count, setCount] = useState(0); - return
{count}
; - } - - // Anonymous function with name - const NonExportedAnon = (function MyNamedAnon() { - const [count, setCount] = useState(0); - return
{count}
; - }); - - // Anonymous function without name - const NonExportedAnonUnnamed = (function() { - const [count, setCount] = useState(0); - return
{count}
; - }); - - expectRegistered(NonExportedFunc, "non_exported_hooks.tsx", "NonExportedFunc"); - expectRegistered(NonExportedVar, "non_exported_hooks.tsx", "NonExportedVar"); - expectRegistered(NonExportedAnon, "non_exported_hooks.tsx", "NonExportedAnon"); - expectRegistered(NonExportedAnonUnnamed, "non_exported_hooks.tsx", "NonExportedAnonUnnamed"); - `, - }, - async test(dev) { - await using c = await dev.client("/"); - }, -}); devTest("default export same-scope handling", { files: { ...reactRefreshStub, diff --git a/test/bake/dev/esm.test.ts b/test/bake/dev/esm.test.ts index bfa9a93d1a..f83a0e7050 100644 --- a/test/bake/dev/esm.test.ts +++ b/test/bake/dev/esm.test.ts @@ -1,5 +1,5 @@ // ESM tests are about various esm features in development mode. -import { devTest, minimalFramework } from "../bake-harness"; +import { devTest, emptyHtmlFile, minimalFramework } from "../bake-harness"; const liveBindingTest = devTest("live bindings with `var`", { framework: minimalFramework, @@ -205,3 +205,49 @@ devTest("export { default as y }", { await dev.fetch("/").equals("Value: 2"); }, }); +devTest("export * as namespace", { + files: { + "index.html": emptyHtmlFile({ + scripts: ["index.ts"], + }), + "index.ts": ` + import { ns as renamed } from './module'; + if (typeof renamed !== 'object') throw new Error('renamed should be an object'); + if (renamed.x !== 1) throw new Error('renamed.x should be 1'); + if (renamed.y !== 2) throw new Error('renamed.y should be 2'); + console.log('PASS'); + `, + "module.ts": ` + export * as ns from './module2'; + `, + "module2.ts": ` + export const x = 1; + export const y = 2; + export const ns = "FAIL"; + `, + }, + async test(dev) { + await using c = await dev.client(); + await c.expectMessage("PASS"); + }, +}); +devTest("ESM <-> CJS", { + files: { + "index.html": emptyHtmlFile({ + scripts: ["index.ts"], + }), + "index.ts": ` + await import('./esm'); // TODO: implement sync ESM + const mod = require('./esm'); + if (!mod.__esModule) throw new Error('mod.__esModule should be set'); + console.log('PASS'); + `, + "esm.ts": ` + export const x = 1; + `, + }, + async test(dev) { + await using c = await dev.client(); + await c.expectMessage("PASS"); + }, +}); diff --git a/test/bake/dev/react-spa.test.ts b/test/bake/dev/react-spa.test.ts index c5b069f43a..828bc868c7 100644 --- a/test/bake/dev/react-spa.test.ts +++ b/test/bake/dev/react-spa.test.ts @@ -1,8 +1,97 @@ // these tests involve ensuring react (html loader + single page app) works // react is big and we do lots of stuff like fast refresh. import { expect } from "bun:test"; -import { devTest } from "../bake-harness"; +import { devTest, emptyHtmlFile, minimalFramework } from "../bake-harness"; +/** To test react refresh's registration system */ +const reactAndRefreshStub = { + "node_modules/react-refresh/runtime.js": /* js */ ` + exports.performReactRefresh = () => {}; + exports.injectIntoGlobalHook = () => {}; + exports.register = require("bun-devserver-react-mock").register; + exports.createSignatureFunctionForTransform = require("bun-devserver-react-mock").createSignatureFunctionForTransform; + `, + "node_modules/react/index.js": /* js */ ` + exports.useState = (y) => [y, x => {}]; + `, + "node_modules/bun-devserver-react-mock/index.js": /* js */ ` + globalThis.components = new Map(); + globalThis.functionToComponent = new Map(); + exports.expectComponent = function(fn, filename, exportId) { + const name = filename + ":" + exportId; + try { + if (!components.has(name)) { + for (const [k, v] of components) { + if (v.fn === fn) throw new Error("Component registered under name " + k + " instead of " + name); + } + throw new Error("Component not registered: " + name); + } + if (components.get(name).fn !== fn) throw new Error("Component registered with wrong name: " + name); + } catch (e) { + console.log(components); + throw e; + } + } + exports.expectHook = function(fn) { + if (!functionToComponent.has(fn)) throw new Error("Hook not registered: " + fn.name); + const entry = functionToComponent.get(fn); + const { calls, hash, name } = entry; + fn(); + if (calls === entry.calls) throw new Error("Hook " + (name ?? fn.name) + " was not called"); + return hash; + } + exports.expectHookComponent = function(fn, filename, exportId) { + exports.expectComponent(fn, filename, exportId); + exports.expectHook(fn); + } + exports.hashFromFunction = function(fn) { + if (!keyFromFunction.has(fn)) throw new Error("Function not registered: " + fn); + return keyFromFunction.get(fn).hash; + } + exports.register = function(fn, name) { + if (typeof name !== "string") throw new Error("name must be a string"); + if (typeof fn !== "function") throw new Error("fn must be a function"); + if (components.has(name)) throw new Error("Component already registered: " + name + ". Read its hash from test harness first"); + const entry = functionToComponent.get(fn) ?? { fn, calls: 0, hash: undefined, name: undefined, customHooks: undefined }; + entry.name = name; + components.set(name, entry); + functionToComponent.set(fn, entry); + } + exports.createSignatureFunctionForTransform = function(fn) { + let entry = null; + return function(fn, hash, force, customHooks) { + if (fn !== undefined) { + entry = functionToComponent.get(fn) ?? { fn, calls: 0, hash: undefined, name: undefined, customHooks: undefined }; + functionToComponent.set(fn, entry); + entry.hash = hash; + entry.calls = 0; + entry.customHooks = customHooks; + return fn; + } else { + if (!entry) throw new Error("Function not registered"); + entry.calls++; + return entry.fn; + } + } + } + exports.getCustomHooks = function(fn) { + const entry = functionToComponent.get(fn); + if (!entry) throw new Error("Function not registered"); + if (!entry.customHooks) throw new Error("Function has no custom hooks"); + return entry.customHooks(); + } + `, + "node_modules/react/jsx-dev-runtime.js": /* js */ ` + export const $$typeof = Symbol.for("react.element"); + export const jsxDEV = (tag, props, key) => ({ + $$typeof, + props, + key, + ref: null, + type: tag, + }); + `, +}; devTest("react in html", { fixture: "react-spa-simple", async test(dev) { @@ -28,3 +117,311 @@ devTest("react in html", { expect(await c.elemText("h1")).toBe("Yay"); }, }); +// https://github.com/oven-sh/bun/issues/17447 +devTest("react refresh should register and track hook state", { + framework: minimalFramework, + files: { + ...reactAndRefreshStub, + "index.html": emptyHtmlFile({ + styles: [], + scripts: ["index.tsx"], + }), + "index.tsx": ` + import { expectHookComponent } from 'bun-devserver-react-mock'; + import App from './App.tsx'; + expectHookComponent(App, "App.tsx", "default"); + `, + "App.tsx": ` + import { useState } from "react"; + export default function App() { + let [a, b] = useState(1); + return
Hello, world!
; + } + `, + }, + async test(dev) { + await using c = await dev.client("/", {}); + const firstHash = await c.reactRefreshComponentHash("App.tsx", "default"); + expect(firstHash).toBeDefined(); + + // hash does not change when hooks stay same + await dev.write( + "App.tsx", + ` + import { useState } from "react"; + export default function App() { + let [a, b] = useState(1); + return
Hello, world! {a}
; + } + `, + ); + const secondHash = await c.reactRefreshComponentHash("App.tsx", "default"); + expect(secondHash).toEqual(firstHash); + + // hash changes when hooks change + await dev.write( + "App.tsx", + ` + export default function App() { + let [a, b] = useState(2); + return
Hello, world! {a}
; + } + `, + ); + const thirdHash = await c.reactRefreshComponentHash("App.tsx", "default"); + expect(thirdHash).not.toEqual(firstHash); + }, +}); +devTest("react refresh cases", { + framework: minimalFramework, + files: { + ...reactAndRefreshStub, + "index.html": emptyHtmlFile({ + styles: [], + scripts: ["index.tsx"], + }), + "index.tsx": ` + import { expectComponent, expectHookComponent } from 'bun-devserver-react-mock'; + + expectComponent((await import("./default_unnamed")).default, "default_unnamed.tsx", "default"); + expectComponent((await import("./default_named")).default, "default_named.tsx", "default"); + expectComponent((await import("./default_arrow")).default, "default_arrow.tsx", "default"); + expectComponent((await import("./local_var")).LocalVar, "local_var.tsx", "LocalVar"); + expectComponent((await import("./local_const")).LocalConst, "local_const.tsx", "LocalConst"); + await import("./non_exported"); + + expectHookComponent((await import("./default_unnamed_hooks")).default, "default_unnamed_hooks.tsx", "default"); + expectHookComponent((await import("./default_named_hooks")).default, "default_named_hooks.tsx", "default"); + expectHookComponent((await import("./default_arrow_hooks")).default, "default_arrow_hooks.tsx", "default"); + expectHookComponent((await import("./local_var_hooks")).LocalVar, "local_var_hooks.tsx", "LocalVar"); + expectHookComponent((await import("./local_const_hooks")).LocalConst, "local_const_hooks.tsx", "LocalConst"); + await import("./non_exported_hooks"); + + console.log("PASS"); + `, + "default_unnamed.tsx": ` + export default function() { + return
; + } + `, + "default_named.tsx": ` + export default function Hello() { + return
; + } + `, + "default_arrow.tsx": ` + export default () => { + return
; + } + `, + "local_var.tsx": ` + export var LocalVar = () => { + return
; + } + `, + "local_const.tsx": ` + export const LocalConst = () => { + return
; + } + `, + "non_exported.tsx": ` + import { expectComponent } from 'bun-devserver-react-mock'; + + function NonExportedFunc() { + return
; + } + + const NonExportedVar = () => { + return
; + } + + // Anonymous function with name + const NonExportedAnon = (function MyNamedAnon() { + return
; + }); + + // Anonymous function without name + const NonExportedAnonUnnamed = (function() { + return
; + }); + + expectComponent(NonExportedFunc, "non_exported.tsx", "NonExportedFunc"); + expectComponent(NonExportedVar, "non_exported.tsx", "NonExportedVar"); + expectComponent(NonExportedAnon, "non_exported.tsx", "NonExportedAnon"); + expectComponent(NonExportedAnonUnnamed, "non_exported.tsx", "NonExportedAnonUnnamed"); + `, + "default_unnamed_hooks.tsx": ` + import { useState } from "react"; + export default function() { + const [count, setCount] = useState(0); + return
{count}
; + } + `, + "default_named_hooks.tsx": ` + import { useState } from "react"; + export default function Hello() { + const [count, setCount] = useState(0); + return
{count}
; + } + `, + "default_arrow_hooks.tsx": ` + import { useState } from "react"; + export default () => { + const [count, setCount] = useState(0); + return
{count}
; + } + `, + "local_var_hooks.tsx": ` + import { useState } from "react"; + export var LocalVar = () => { + const [count, setCount] = useState(0); + return
{count}
; + } + `, + "local_const_hooks.tsx": ` + import { useState } from "react"; + export const LocalConst = () => { + const [count, setCount] = useState(0); + return
{count}
; + } + `, + "non_exported_hooks.tsx": ` + import { useState } from "react"; + import { expectHookComponent } from 'bun-devserver-react-mock'; + + function NonExportedFunc() { + const [count, setCount] = useState(0); + return
{count}
; + } + + const NonExportedVar = () => { + const [count, setCount] = useState(0); + return
{count}
; + } + + // Anonymous function with name + const NonExportedAnon = (function MyNamedAnon() { + const [count, setCount] = useState(0); + return
{count}
; + }); + + // Anonymous function without name + const NonExportedAnonUnnamed = (function() { + const [count, setCount] = useState(0); + return
{count}
; + }); + + expectHookComponent(NonExportedFunc, "non_exported_hooks.tsx", "NonExportedFunc"); + expectHookComponent(NonExportedVar, "non_exported_hooks.tsx", "NonExportedVar"); + expectHookComponent(NonExportedAnon, "non_exported_hooks.tsx", "NonExportedAnon"); + expectHookComponent(NonExportedAnonUnnamed, "non_exported_hooks.tsx", "NonExportedAnonUnnamed"); + `, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.expectMessage("PASS"); + }, +}); +devTest("two functions with hooks should be independently tracked", { + framework: minimalFramework, + files: { + ...reactAndRefreshStub, + "index.html": emptyHtmlFile({ + styles: [], + scripts: ["index.tsx"], + }), + "index.tsx": ` + import { useState } from "react"; + import { expectHook } from 'bun-devserver-react-mock'; + + function method1() { + const _ = useState(1); + } + const method2 = function method2() { + const _ = useState(2); + } + const method3 = () => { + const _ = useState(3); + } + + expectHook(method1); + expectHook(method2); + expectHook(method3); + + console.log("PASS"); + `, + }, + async test(dev) { + await using c = await dev.client("/", {}); + await c.expectMessage("PASS"); + }, +}); +devTest("custom hook tracking", { + framework: minimalFramework, + files: { + ...reactAndRefreshStub, + "index.html": emptyHtmlFile({ + styles: [], + scripts: ["index.tsx"], + }), + "index.tsx": ` + import { useCustom1, useCustom2 } from "./custom-hook"; + import { expectHook, getCustomHooks } from 'bun-devserver-react-mock'; + + function method1() { + const _ = useCustom1(); + } + function method2() { + const _ = useCustom1(); + } + function method3() { + const _ = useCustom2(); + } + function method4() { + const a = useCustom1(); + const b = useCustom2(); + } + + const hash1 = expectHook(method1); + const hash2 = expectHook(method2); + const hash3 = expectHook(method3); + const hash4 = expectHook(method4); + + if (hash1 !== hash2) throw new Error("hash1 and hash2 should be the same: " + hash1 + " " + hash2); + if (hash1 === hash3) throw new Error("hash1 and hash3 should be different: " + hash1 + " " + hash3); + if (hash1 === hash4) throw new Error("hash1 and hash4 should be different: " + hash1 + " " + hash4); + if (hash3 === hash4) throw new Error("hash3 and hash4 should be different: " + hash3 + " " + hash4); + + const customHooks1 = getCustomHooks(method1); + const customHooks2 = getCustomHooks(method2); + const customHooks3 = getCustomHooks(method3); + + function assertCustomHooks(method, expected) { + const customHooks = getCustomHooks(method); + if (customHooks.length !== expected.length) throw new Error("customHooks should have " + expected.length + " hooks: " + customHooks.length); + for (let i = 0; i < expected.length; i++) { + if (customHooks[i] !== expected[i]) throw new Error(\`customHooks[\${i}] should be \${expected[i]} but got \${customHooks[i]}\`); + } + } + + assertCustomHooks(method1, [useCustom1]); + assertCustomHooks(method2, [useCustom1]); + assertCustomHooks(method3, [useCustom2]); + assertCustomHooks(method4, [useCustom1, useCustom2]); + + console.log("PASS"); + `, + "custom-hook.ts": ` + export function useCustom1() { + return 1; + } + export function useCustom2() { + return 2; + } + `, + }, + async test(dev) { + await using c = await dev.client("/", {}); + await c.expectMessage("PASS"); + }, +});