diff options
author | 2021-06-20 18:15:13 -0700 | |
---|---|---|
committer | 2021-06-20 18:15:13 -0700 | |
commit | d09194f05a372e3ed136aa288ae76cae8c1dc641 (patch) | |
tree | ab7e49f9793bc493d89274773d444ac59c0d3163 | |
parent | 6fbfd696990e77020a3d7359fdcbc3e01de40a60 (diff) | |
download | bun-d09194f05a372e3ed136aa288ae76cae8c1dc641.tar.gz bun-d09194f05a372e3ed136aa288ae76cae8c1dc641.tar.zst bun-d09194f05a372e3ed136aa288ae76cae8c1dc641.zip |
Support live-reload and fallback
Former-commit-id: c3f9d77391589b65951616a632af87107fba469f
-rw-r--r-- | demos/react-fast-refresh-test/src/components/RenderCounter.tsx | 21 | ||||
-rw-r--r-- | demos/react-fast-refresh-test/src/components/app.tsx | 14 | ||||
-rw-r--r-- | demos/react-fast-refresh-test/src/components/button.tsx | 38 | ||||
-rw-r--r-- | demos/react-fast-refresh-test/src/components/new-comp.tsx | 3 | ||||
-rw-r--r-- | demos/react-fast-refresh-test/src/index.css | 200 | ||||
-rw-r--r-- | demos/react-fast-refresh-test/src/index.tsx | 11 | ||||
-rw-r--r-- | src/api/schema.d.ts | 35 | ||||
-rw-r--r-- | src/api/schema.js | 126 | ||||
-rw-r--r-- | src/api/schema.peechy | 11 | ||||
-rw-r--r-- | src/api/schema.zig | 88 | ||||
-rw-r--r-- | src/http.zig | 12 | ||||
-rw-r--r-- | src/js_ast.zig | 15 | ||||
-rw-r--r-- | src/js_parser/js_parser.zig | 106 | ||||
-rw-r--r-- | src/node_module_bundle.zig | 5 | ||||
-rw-r--r-- | src/runtime.footer.js | 14 | ||||
-rw-r--r-- | src/runtime.version | 2 | ||||
-rw-r--r-- | src/runtime.zig | 1 | ||||
-rw-r--r-- | src/runtime/hmr.ts | 1698 |
18 files changed, 1464 insertions, 936 deletions
diff --git a/demos/react-fast-refresh-test/src/components/RenderCounter.tsx b/demos/react-fast-refresh-test/src/components/RenderCounter.tsx new file mode 100644 index 000000000..ed2f00b56 --- /dev/null +++ b/demos/react-fast-refresh-test/src/components/RenderCounter.tsx @@ -0,0 +1,21 @@ +import React from "react"; + +export function RenderCounter({ name, children }) { + const counter = React.useRef(1); + return ( + <div className="RenderCounter"> + <div className="RenderCounter-meta"> + <div className="RenderCounter-title"> + {name} rendered <strong>{counter.current++} times</strong> + </div> + <div className="RenderCounter-lastRender"> + LAST RENDER:{" "} + {new Intl.DateTimeFormat([], { + timeStyle: "long", + }).format(new Date())} + </div> + </div> + <div className="RenderCounter-children">{children}</div> + </div> + ); +} diff --git a/demos/react-fast-refresh-test/src/components/app.tsx b/demos/react-fast-refresh-test/src/components/app.tsx new file mode 100644 index 000000000..2edc02545 --- /dev/null +++ b/demos/react-fast-refresh-test/src/components/app.tsx @@ -0,0 +1,14 @@ +import * as React from "react"; +import { Button } from "./Button"; +import { RenderCounter } from "./RenderCounter"; +export function App() { + return ( + <RenderCounter name="App"> + <div className="AppRoot"> + <h1>This is the root element</h1> + + <Button>Click</Button> + </div> + </RenderCounter> + ); +} diff --git a/demos/react-fast-refresh-test/src/components/button.tsx b/demos/react-fast-refresh-test/src/components/button.tsx index 3f55fae34..4c3388670 100644 --- a/demos/react-fast-refresh-test/src/components/button.tsx +++ b/demos/react-fast-refresh-test/src/components/button.tsx @@ -1,37 +1,9 @@ -import React from "react"; -import { NewComponent } from "./new-comp"; - -const Toast = () => { - const [baconyes, baconno] = useBacon(); - return <div>false</div>; -}; -const Button = ({ label, label2, onClick }) => { - const useCustomHookInsideFunction = (what, arr) => { - return [true, false]; - }; - const [on, setOn] = React.useState(false); - - React.useEffect(() => { - console.log({ on }); - }, [on]); - - // const [foo1, foo2] = useCustomHookInsideFunction(() => {}, [on]); +import { RenderCounter } from "./RenderCounter"; +export const Button = ({ children }) => { return ( - <div className="Button" onClick={onClick}> - <Toast>f</Toast> - <div className="Button-label">{label}12</div> - <NewComponent /> - </div> + <RenderCounter name="Button"> + <div className="Button">{children}</div> + </RenderCounter> ); }; - -const Bacon = Button; - -export { Bacon, Bacon as Button }; - -const RefreshLike = () => {}; - -const useBacon = () => { - return [1, 8]; -}; diff --git a/demos/react-fast-refresh-test/src/components/new-comp.tsx b/demos/react-fast-refresh-test/src/components/new-comp.tsx deleted file mode 100644 index f09c64a54..000000000 --- a/demos/react-fast-refresh-test/src/components/new-comp.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export const NewComponent = () => { - return <div>NEW!</div>; -}; diff --git a/demos/react-fast-refresh-test/src/index.css b/demos/react-fast-refresh-test/src/index.css index 0917f6c7a..c4514199c 100644 --- a/demos/react-fast-refresh-test/src/index.css +++ b/demos/react-fast-refresh-test/src/index.css @@ -42,18 +42,6 @@ body { height: 100%; } -.Subtitle { - text-align: center; - font-size: 4em; - margin: 0; - padding: 0; - margin-bottom: 0.25em; - - align-items: center; - display: flex; - flex-direction: row; -} - #reactroot, #__next, body, @@ -61,176 +49,50 @@ html { height: 100%; } -.Title { - color: var(--color-brand); - font-family: var(--heading-font); - font-weight: 700; - margin-top: 48px; - font-size: 48px; - text-transform: capitalize; - text-align: center; -} - -.Description { - text-align: center; -} - -.main { - display: flex; - flex-direction: column; - height: 100%; -} - -header, -.main { - width: 650px; - margin: 0 auto; -} - -section { - width: 650px; -} - -header { - margin-bottom: 48px; -} - -footer { - flex-shrink: 0; -} - -#reactroot, -#__next { - display: flex; - flex-direction: column; - justify-content: center; +.RenderCounter { + border: 10px solid var(--snippets_container-background-focused); + margin: 10px; + padding: 10px; + animation: flash 0.2s linear; + animation-fill-mode: forwards; } -section { - height: 300px; +.RenderCounter-meta { display: flex; - flex-direction: column; + flex-direction: row; + justify-content: space-between; + margin: -10px; + padding: 10px; + background-color: #111; } -.timer { - font-weight: normal; +.RenderCounter-lastRender, +.RenderCounter-title { + white-space: nowrap; + color: rgb(153, 153, 153); } -.ProgressBar-container { - width: 100%; - display: block; - position: relative; - border: 1px solid var(--color-brand-muted); - border-radius: 4px; +@keyframes flash { + from { + border-color: var(--snippets_container-background-focused); + } - height: 92px; + to { + border-color: var(--snippets_container-background-unfocused); + } } -.ProgressBar { - position: absolute; - top: 0; - bottom: 0; - right: 0; - left: 0; - width: 100%; - height: 100%; +.Button { display: block; - background-color: var(--color-brand); - transform-origin: top left; - border-radius: 4px; - transform: scaleX(var(--progress-bar, 0%)); -} - -.Bundler-container { - background-color: var(--snippets_container-background-focused); - font-size: 64px; - font-weight: bold; - color: white; - left: 0; - right: 0; - padding: 0.8em 0.8em; -} - -.Bundler-updateRate { - font-size: 0.8em; - font-weight: normal; - display: flex; - color: var(--result__muted-color); -} -.interval:before { - content: var(--interval, "16"); -} - -.highlight { + border: 1px solid rgb(20, 180, 0); + background-color: rgb(2, 150, 0); color: white; -} - -.timer:after { - content: var(--timestamp); - font-variant-numeric: tabular-nums; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, - Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; - display: inline; font-weight: 500; - color: white; - width: 100%; -} - -.SectionLabel { - font-weight: 300; - font-family: var(--heading-font); + padding: 10px 12px; + border-radius: 4px; + text-transform: uppercase; text-align: center; - width: 100%; - font-weight: 700; - margin-top: 24px; -} - -.FooterLabel { - margin-top: 0; - margin-bottom: 12px; -} - -.Spinner-container { - --spinner-muted: rgb(0, 255, 0); - --spinner-primary: rgb(0, 60, 255); - - width: 96px; - height: 96px; - border-radius: 50%; - background-color: var(--page-background); - border-top: 1.1em solid var(--spinner-muted); - border-right: 1.1em solid var(--spinner-muted); - border-bottom: 1.1em solid var(--spinner-muted); - border-left: 1.1em solid var(--spinner-primary); - - transform: rotate(var(--spinner-rotate, 12deg)); -} - -.Spinners { - display: grid; - grid-auto-flow: column; - justify-content: space-between; - - width: 100%; -} - -.Spinner-1.Spinner-container { - --spinner-muted: var(--spinner-1-muted); - --spinner-primary: var(--spinner-1-primary); -} - -.Spinner-2.Spinner-container { - --spinner-muted: var(--spinner-2-muted); - --spinner-primary: var(--spinner-2-primary); -} - -.Spinner-3.Spinner-container { - --spinner-muted: var(--spinner-3-muted); - --spinner-primary: var(--spinner-3-primary); -} - -.Spinner-4.Spinner-container { - --spinner-muted: var(--spinner-4-muted); - --spinner-primary: var(--spinner-4-primary); + width: fit-content; + cursor: pointer; } diff --git a/demos/react-fast-refresh-test/src/index.tsx b/demos/react-fast-refresh-test/src/index.tsx index 3db53a67f..348bd80f2 100644 --- a/demos/react-fast-refresh-test/src/index.tsx +++ b/demos/react-fast-refresh-test/src/index.tsx @@ -1,15 +1,10 @@ import ReactDOM from "react-dom"; import React from "react"; -import { Main } from "./main"; +import { App } from "./components/app"; import classNames from "classnames"; -const Base = ({}) => { - const name = decodeURIComponent(location.search.substring(1)); - return <Main productName={name || "Bundler"} />; -}; - function startReact() { - ReactDOM.render(<Base />, document.querySelector("#reactroot")); + ReactDOM.render(<App />, document.querySelector("#reactroot")); } globalThis.addEventListener("DOMContentLoaded", () => { @@ -17,4 +12,4 @@ globalThis.addEventListener("DOMContentLoaded", () => { }); startReact(); -export { Base }; +export { App }; diff --git a/src/api/schema.d.ts b/src/api/schema.d.ts index 367932105..a15f7b878 100644 --- a/src/api/schema.d.ts +++ b/src/api/schema.d.ts @@ -119,6 +119,19 @@ type uint32 = number; 4: "debug", debug: "debug" } + export enum Reloader { + disable = 1, + live = 2, + fast_refresh = 3 + } + export const ReloaderKeys = { + 1: "disable", + disable: "disable", + 2: "live", + live: "live", + 3: "fast_refresh", + fast_refresh: "fast_refresh" + } export enum WebsocketMessageKind { welcome = 1, file_change_notification = 2, @@ -301,6 +314,7 @@ type uint32 = number; export interface WebsocketMessageWelcome { epoch: uint32; + javascriptReloader: Reloader; } export interface WebsocketMessageFileChangeNotification { @@ -341,6 +355,21 @@ type uint32 = number; ids: Uint32Array; } + export interface FileList { + ptrs: StringPointer[]; + files: string; + } + + export interface WebsocketMessageResolveIDs { + id: Uint32Array; + list: FileList; + } + + export interface WebsocketCommandResolveIDs { + ptrs: StringPointer[]; + files: string; + } + export interface WebsocketMessageManifestSuccess { id: uint32; module_path: string; @@ -411,6 +440,12 @@ type uint32 = number; export declare function decodeWebsocketMessageBuildFailure(buffer: ByteBuffer): WebsocketMessageBuildFailure; export declare function encodeDependencyManifest(message: DependencyManifest, bb: ByteBuffer): void; export declare function decodeDependencyManifest(buffer: ByteBuffer): DependencyManifest; + export declare function encodeFileList(message: FileList, bb: ByteBuffer): void; + export declare function decodeFileList(buffer: ByteBuffer): FileList; + export declare function encodeWebsocketMessageResolveIDs(message: WebsocketMessageResolveIDs, bb: ByteBuffer): void; + export declare function decodeWebsocketMessageResolveIDs(buffer: ByteBuffer): WebsocketMessageResolveIDs; + export declare function encodeWebsocketCommandResolveIDs(message: WebsocketCommandResolveIDs, bb: ByteBuffer): void; + export declare function decodeWebsocketCommandResolveIDs(buffer: ByteBuffer): WebsocketCommandResolveIDs; export declare function encodeWebsocketMessageManifestSuccess(message: WebsocketMessageManifestSuccess, bb: ByteBuffer): void; export declare function decodeWebsocketMessageManifestSuccess(buffer: ByteBuffer): WebsocketMessageManifestSuccess; export declare function encodeWebsocketMessageManifestFailure(message: WebsocketMessageManifestFailure, bb: ByteBuffer): void; diff --git a/src/api/schema.js b/src/api/schema.js index f460064e5..c4341c1d4 100644 --- a/src/api/schema.js +++ b/src/api/schema.js @@ -1236,6 +1236,22 @@ function encodeLog(message, bb) { } } +const Reloader = { + "1": 1, + "2": 2, + "3": 3, + "disable": 1, + "live": 2, + "fast_refresh": 3 +}; +const ReloaderKeys = { + "1": "disable", + "2": "live", + "3": "fast_refresh", + "disable": "disable", + "live": "live", + "fast_refresh": "fast_refresh" +}; const WebsocketMessageKind = { "1": 1, "2": 2, @@ -1309,6 +1325,7 @@ function decodeWebsocketMessageWelcome(bb) { var result = {}; result["epoch"] = bb.readUint32(); + result["javascriptReloader"] = Reloader[bb.readByte()]; return result; } @@ -1321,6 +1338,15 @@ function encodeWebsocketMessageWelcome(message, bb) { throw new Error("Missing required field \"epoch\""); } + var value = message["javascriptReloader"]; + if (value != null) { + var encoded = Reloader[value]; +if (encoded === void 0) throw new Error("Invalid value " + JSON.stringify(value) + " for enum \"Reloader\""); +bb.writeByte(encoded); + } else { + throw new Error("Missing required field \"javascriptReloader\""); + } + } function decodeWebsocketMessageFileChangeNotification(bb) { @@ -1537,6 +1563,98 @@ function encodeDependencyManifest(message, bb) { } +function decodeFileList(bb) { + var result = {}; + + var length = bb.readVarUint(); + var values = result["ptrs"] = Array(length); + for (var i = 0; i < length; i++) values[i] = decodeStringPointer(bb); + result["files"] = bb.readString(); + return result; +} + +function encodeFileList(message, bb) { + + var value = message["ptrs"]; + if (value != null) { + var values = value, n = values.length; + bb.writeVarUint(n); + for (var i = 0; i < n; i++) { + value = values[i]; + encodeStringPointer(value, bb); + } + } else { + throw new Error("Missing required field \"ptrs\""); + } + + var value = message["files"]; + if (value != null) { + bb.writeString(value); + } else { + throw new Error("Missing required field \"files\""); + } + +} + +function decodeWebsocketMessageResolveIDs(bb) { + var result = {}; + + result["id"] = bb.readUint32ByteArray(); + result["list"] = decodeFileList(bb); + return result; +} + +function encodeWebsocketMessageResolveIDs(message, bb) { + + var value = message["id"]; + if (value != null) { + bb.writeUint32ByteArray(value); + } else { + throw new Error("Missing required field \"id\""); + } + + var value = message["list"]; + if (value != null) { + encodeFileList(value, bb); + } else { + throw new Error("Missing required field \"list\""); + } + +} + +function decodeWebsocketCommandResolveIDs(bb) { + var result = {}; + + var length = bb.readVarUint(); + var values = result["ptrs"] = Array(length); + for (var i = 0; i < length; i++) values[i] = decodeStringPointer(bb); + result["files"] = bb.readString(); + return result; +} + +function encodeWebsocketCommandResolveIDs(message, bb) { + + var value = message["ptrs"]; + if (value != null) { + var values = value, n = values.length; + bb.writeVarUint(n); + for (var i = 0; i < n; i++) { + value = values[i]; + encodeStringPointer(value, bb); + } + } else { + throw new Error("Missing required field \"ptrs\""); + } + + var value = message["files"]; + if (value != null) { + bb.writeString(value); + } else { + throw new Error("Missing required field \"files\""); + } + +} + function decodeWebsocketMessageManifestSuccess(bb) { var result = {}; @@ -1679,6 +1797,8 @@ export { decodeMessage } export { encodeMessage } export { decodeLog } export { encodeLog } +export { Reloader } +export { ReloaderKeys } export { WebsocketMessageKind } export { WebsocketMessageKindKeys } export { WebsocketCommandKind } @@ -1701,6 +1821,12 @@ export { decodeWebsocketMessageBuildFailure } export { encodeWebsocketMessageBuildFailure } export { decodeDependencyManifest } export { encodeDependencyManifest } +export { decodeFileList } +export { encodeFileList } +export { decodeWebsocketMessageResolveIDs } +export { encodeWebsocketMessageResolveIDs } +export { decodeWebsocketCommandResolveIDs } +export { encodeWebsocketCommandResolveIDs } export { decodeWebsocketMessageManifestSuccess } export { encodeWebsocketMessageManifestSuccess } export { decodeWebsocketMessageManifestFailure } diff --git a/src/api/schema.peechy b/src/api/schema.peechy index 477ce5d0b..74050e90f 100644 --- a/src/api/schema.peechy +++ b/src/api/schema.peechy @@ -235,9 +235,17 @@ struct Log { } +smol Reloader { + disable = 1; + // equivalent of CMD + R + live = 2; + // React Fast Refresh + fast_refresh = 3; +} + // The WebSocket protocol // Server: "hey, this file changed. Does anyone want it?" -// Client: *checks hash table* "uhh yeah, ok. rebuild that for me" +// Browser: *checks array* "uhh yeah, ok. rebuild that for me" // Server: "here u go" // This makes the client responsible for tracking which files it needs to listen for. // From a server perspective, this means the filesystem watching thread can send the same WebSocket message @@ -267,6 +275,7 @@ struct WebsocketMessage { // This is the first. struct WebsocketMessageWelcome { uint32 epoch; + Reloader javascriptReloader; } struct WebsocketMessageFileChangeNotification { diff --git a/src/api/schema.zig b/src/api/schema.zig index 00460fcc6..8227f5e88 100644 --- a/src/api/schema.zig +++ b/src/api/schema.zig @@ -1271,6 +1271,24 @@ pub const Api = struct { } }; + pub const Reloader = enum(u8) { + _none, + /// disable + disable, + + /// live + live, + + /// fast_refresh + fast_refresh, + + _, + + pub fn jsonStringify(self: *const @This(), opts: anytype, o: anytype) !void { + return try std.json.stringify(@tagName(self), opts, o); + } + }; + pub const WebsocketMessageKind = enum(u8) { _none, /// welcome @@ -1334,19 +1352,24 @@ pub const Api = struct { } }; - pub const WebsocketMessageWelcome = packed struct { + pub const WebsocketMessageWelcome = struct { /// epoch epoch: u32 = 0, + /// javascriptReloader + javascript_reloader: Reloader, + pub fn decode(reader: anytype) anyerror!WebsocketMessageWelcome { var this = std.mem.zeroes(WebsocketMessageWelcome); this.epoch = try reader.readValue(u32); + this.javascript_reloader = try reader.readValue(Reloader); return this; } pub fn encode(this: *const @This(), writer: anytype) anyerror!void { try writer.writeInt(this.epoch); + try writer.writeEnum(this.javascript_reloader); } }; @@ -1512,6 +1535,69 @@ pub const Api = struct { } }; + pub const FileList = struct { + /// ptrs + ptrs: []const StringPointer, + + /// files + files: []const u8, + + pub fn decode(reader: anytype) anyerror!FileList { + var this = std.mem.zeroes(FileList); + + this.ptrs = try reader.readArray(StringPointer); + this.files = try reader.readValue([]const u8); + return this; + } + + pub fn encode(this: *const @This(), writer: anytype) anyerror!void { + try writer.writeArray(StringPointer, this.ptrs); + try writer.writeValue(this.files); + } + }; + + pub const WebsocketMessageResolveIDs = struct { + /// id + id: []const u32, + + /// list + list: FileList, + + pub fn decode(reader: anytype) anyerror!WebsocketMessageResolveIDs { + var this = std.mem.zeroes(WebsocketMessageResolveIDs); + + this.id = try reader.readArray(u32); + this.list = try reader.readValue(FileList); + return this; + } + + pub fn encode(this: *const @This(), writer: anytype) anyerror!void { + try writer.writeArray(u32, this.id); + try writer.writeValue(this.list); + } + }; + + pub const WebsocketCommandResolveIDs = struct { + /// ptrs + ptrs: []const StringPointer, + + /// files + files: []const u8, + + pub fn decode(reader: anytype) anyerror!WebsocketCommandResolveIDs { + var this = std.mem.zeroes(WebsocketCommandResolveIDs); + + this.ptrs = try reader.readArray(StringPointer); + this.files = try reader.readValue([]const u8); + return this; + } + + pub fn encode(this: *const @This(), writer: anytype) anyerror!void { + try writer.writeArray(StringPointer, this.ptrs); + try writer.writeValue(this.files); + } + }; + pub const WebsocketMessageManifestSuccess = struct { /// id id: u32 = 0, diff --git a/src/http.zig b/src/http.zig index 269274b56..665f7e978 100644 --- a/src/http.zig +++ b/src/http.zig @@ -757,8 +757,20 @@ pub const RequestContext = struct { var writer = ByteApiWriter.init(&fbs); try msg.encode(&writer); + var reloader = Api.Reloader.disable; + if (ctx.bundler.options.hot_module_reloading) { + reloader = Api.Reloader.live; + if (ctx.bundler.options.jsx.supports_fast_refresh) { + if (ctx.bundler.options.node_modules_bundle) |bundle| { + if (bundle.hasFastRefresh()) { + reloader = Api.Reloader.fast_refresh; + } + } + } + } const welcome_message = Api.WebsocketMessageWelcome{ .epoch = WebsocketHandler.toTimestamp(handler.ctx.timer.start_time), + .javascript_reloader = reloader, }; try welcome_message.encode(&writer); if ((try handler.websocket.writeBinary(fbs.getWritten())) == 0) { diff --git a/src/js_ast.zig b/src/js_ast.zig index 9df3e36f0..4eb2450e3 100644 --- a/src/js_ast.zig +++ b/src/js_ast.zig @@ -758,6 +758,21 @@ pub const Symbol = struct { pub fn isKindFunction(kind: Symbol.Kind) bool { return kind == Symbol.Kind.hoisted_function or kind == Symbol.Kind.generator_or_async_function; } + + pub fn isReactComponentishName(symbol: *const Symbol) bool { + switch (symbol.kind) { + .hoisted, .hoisted_function, .cconst, .class, .other => { + return switch (symbol.original_name[0]) { + 'A'...'Z' => true, + else => false, + }; + }, + + else => { + return false; + }, + } + } }; pub const OptionalChain = packed enum(u2) { diff --git a/src/js_parser/js_parser.zig b/src/js_parser/js_parser.zig index 76ffef336..f8b565fe7 100644 --- a/src/js_parser/js_parser.zig +++ b/src/js_parser/js_parser.zig @@ -1677,12 +1677,27 @@ pub const Parser = struct { pub fn parse(self: *Parser) !js_ast.Result { if (self.options.ts and self.options.jsx.parse) { + if (self.options.features.react_fast_refresh) { + return try self._parse(TSXParserFastRefresh); + } return try self._parse(TSXParser); } else if (self.options.ts) { + if (self.options.features.react_fast_refresh) { + return try self._parse(TypeScriptParserFastRefresh); + } + return try self._parse(TypeScriptParser); } else if (self.options.jsx.parse) { + if (self.options.features.react_fast_refresh) { + return try self._parse(JSXParserFastRefresh); + } + return try self._parse(JSXParser); } else { + if (self.options.features.react_fast_refresh) { + return try self._parse(JavaScriptParserFastRefresh); + } + return try self._parse(JavaScriptParser); } } @@ -2194,11 +2209,73 @@ pub const ImportOrRequireScanResults = struct { import_records: List(ImportRecord), }; +const ParserFeatures = struct { + typescript: bool = false, + jsx: bool = false, + scan_only: bool = false, + + // *** How React Fast Refresh works *** + // + // Implmenetations: + // [0]: https://github.com/facebook/react/blob/master/packages/react-refresh/src/ReactFreshBabelPlugin.js + // [1]: https://github.com/swc-project/swc/blob/master/ecmascript/transforms/react/src/refresh/mod.rs + // + // Additional reading: + // - https://github.com/facebook/react/issues/16604#issuecomment-528663101 + // - https://github.com/facebook/react/blob/master/packages/react-refresh/src/__tests__/ReactFreshIntegration-test.js + // + // From reading[0] and Dan Abramov's comment, there are really five parts. + // 1. At the top of the file: + // 1. Declare a $RefreshReg$ if it doesn't exist + // - This really just does "RefreshRuntime.register(ComponentIdentifier, ComponentIdentifier.name);" + // 2. Run "var _s${componentIndex} = $RefreshSig$()" to generate a function for updating react refresh scoped to the component. So it's one per *component*. + // - This really just does "RefreshRuntime.createSignatureFunctionForTransform();" + // 2. Register all React components[2] defined in the module scope by calling the equivalent of $RefreshReg$(ComponentIdentifier, "ComponentName") + // 3. For each registered component: + // 1. Call "_s()" to mark the first render of this component for "react-refresh/runtime". Call this at the start of the React component's function body + // 2. Track every call expression to a hook[3] inside the component, including: + // - Identifier of the hook function + // - Arguments passed + // 3. For each hook's call expression, generate a signature key which is + // - The hook's identifier ref + // - The S.Decl ("VariableDeclarator")'s source + // "var [foo, bar] = useFooBar();" + // ^--------^ This region, I think. Judging from this line: https://github.com/facebook/react/blob/master/packages/react-refresh/src/ReactFreshBabelPlugin.js#L407 + // - For the "useState" hook, also hash the source of the first argument if it exists e.g. useState(foo => true); + // - For the "useReducer" hook, also hash the source of the second argument if it exists e.g. useReducer({}, () => ({})); + // 4. If the hook component is not builtin and is defined inside a component, always reset the component state + // - See this test: https://github.com/facebook/react/blob/568dc3532e25b30eee5072de08503b1bbc4f065d/packages/react-refresh/src/__tests__/ReactFreshIntegration-test.js#L909 + // 4. From the signature key generated in 3., call one of the following: + // - _s(ComponentIdentifier, hash(signature)); + // - _s(ComponentIdentifier, hash(signature), true /* forceReset */); + // - _s(ComponentIdentifier, hash(signature), false /* forceReset */, () => [customHook1, customHook2, customHook3]); + // Note: This step is only strictly required on rebuild. + // 5. if (isReactComponentBoundary(exports)) enqueueUpdateAndHandleErrors(); + // **** FAQ **** + // [2]: Q: From a parser's perspective, what's a component? + // A: typeof name === 'string' && name[0] >= 'A' && name[0] <= 'Z -- https://github.com/facebook/react/blob/568dc3532e25b30eee5072de08503b1bbc4f065d/packages/react-refresh/src/ReactFreshBabelPlugin.js#L42-L44 + // [3]: Q: From a parser's perspective, what's a hook? + // A: /^use[A-Z]/ -- https://github.com/facebook/react/blob/568dc3532e25b30eee5072de08503b1bbc4f065d/packages/react-refresh/src/ReactFreshBabelPlugin.js#L390 + // + // + // + + react_fast_refresh: bool = false, +}; + +// Our implementation diverges somewhat from the official implementation +// Specifically, we use a subclass of HMRModule - FastRefreshModule +// Instead of creating a globally-scoped +const FastRefresh = struct {}; + pub fn NewParser( - comptime is_typescript_enabled: bool, - comptime is_jsx_enabled: bool, - comptime only_scan_imports_and_do_not_visit: bool, + comptime js_parser_features: ParserFeatures, ) type { + const is_typescript_enabled = js_parser_features.typescript; + const is_jsx_enabled = js_parser_features.jsx; + const only_scan_imports_and_do_not_visit = js_parser_features.scan_only; + const is_react_fast_refresh_enabled = js_parser_features.react_fast_refresh; + const ImportRecordList = if (only_scan_imports_and_do_not_visit) *std.ArrayList(ImportRecord) else std.ArrayList(ImportRecord); const NamedImportsType = if (only_scan_imports_and_do_not_visit) *js_ast.Ast.NamedImports else js_ast.Ast.NamedImports; const NeedsJSXType = if (only_scan_imports_and_do_not_visit) bool else void; @@ -13905,15 +13982,20 @@ pub fn NewParser( // Range (min … max): 24.1 ms … 39.7 ms 500 runs // '../../build/macos-x86_64/esdev node_modules/react-dom/cjs/react-dom.development.js --resolve=disable' ran // 1.02 ± 0.07 times faster than '../../esdev.before-comptime-js-parser node_modules/react-dom/cjs/react-dom.development.js --resolve=disable' -const JavaScriptParser = NewParser(false, false, false); -const JSXParser = NewParser(false, true, false); -const TSXParser = NewParser(true, true, false); -const TypeScriptParser = NewParser(true, false, false); - -const JavaScriptImportScanner = NewParser(false, false, true); -const JSXImportScanner = NewParser(false, true, true); -const TSXImportScanner = NewParser(true, true, true); -const TypeScriptImportScanner = NewParser(true, false, true); +const JavaScriptParser = NewParser(.{}); +const JSXParser = NewParser(.{ .jsx = true }); +const TSXParser = NewParser(.{ .jsx = true, .typescript = true }); +const TypeScriptParser = NewParser(.{ .typescript = true }); + +const JavaScriptParserFastRefresh = NewParser(.{ .react_fast_refresh = true }); +const JSXParserFastRefresh = NewParser(.{ .jsx = true, .react_fast_refresh = true }); +const TSXParserFastRefresh = NewParser(.{ .jsx = true, .typescript = true, .react_fast_refresh = true }); +const TypeScriptParserFastRefresh = NewParser(.{ .typescript = true, .react_fast_refresh = true }); + +const JavaScriptImportScanner = NewParser(.{ .scan_only = true }); +const JSXImportScanner = NewParser(.{ .jsx = true, .scan_only = true }); +const TSXImportScanner = NewParser(.{ .jsx = true, .typescript = true, .scan_only = true }); +const TypeScriptImportScanner = NewParser(.{ .typescript = true, .scan_only = true }); // The "await" and "yield" expressions are never allowed in argument lists but // may or may not be allowed otherwise depending on the details of the enclosing diff --git a/src/node_module_bundle.zig b/src/node_module_bundle.zig index 8642336e3..4605df2ef 100644 --- a/src/node_module_bundle.zig +++ b/src/node_module_bundle.zig @@ -36,6 +36,11 @@ pub const NodeModuleBundle = struct { pub const magic_bytes = "#!/usr/bin/env speedy\n\n"; threadlocal var jsbundle_prefix: [magic_bytes.len + 5]u8 = undefined; + // TODO: support preact-refresh, others by not hard coding + pub fn hasFastRefresh(this: *const NodeModuleBundle) bool { + return this.package_name_map.contains("react-refresh"); + } + pub fn loadPackageMap(this: *NodeModuleBundle) !void { this.package_name_map = PackageNameMap.init(this.allocator); var ids = PackageIDMap.init(this.allocator); diff --git a/src/runtime.footer.js b/src/runtime.footer.js new file mode 100644 index 000000000..94a358eb5 --- /dev/null +++ b/src/runtime.footer.js @@ -0,0 +1,14 @@ +// --- +// Public exports from runtime +export var $$m = SPEEDY_RUNTIME.$$m; +export var __HMRModule = SPEEDY_RUNTIME.__HMRModule; +export var __FastRefreshModule = SPEEDY_RUNTIME.__FastRefreshModule; +export var __HMRClient = SPEEDY_RUNTIME.__HMRClient; +export var __markAsModule = SPEEDY_RUNTIME.__markAsModule; +export var $$lzy = SPEEDY_RUNTIME.$$lzy; +export var __toModule = SPEEDY_RUNTIME.__toModule; +export var __commonJS = SPEEDY_RUNTIME.__commonJS; +export var __require = SPEEDY_RUNTIME.__require; +export var __name = SPEEDY_RUNTIME.__name; +export var __export = SPEEDY_RUNTIME.__export; +export var __reExport = SPEEDY_RUNTIME.__reExport; diff --git a/src/runtime.version b/src/runtime.version index b9f5adbff..38984f56a 100644 --- a/src/runtime.version +++ b/src/runtime.version @@ -1 +1 @@ -b735428795c2ee2d
\ No newline at end of file +2a14be7fb8890d70
\ No newline at end of file diff --git a/src/runtime.zig b/src/runtime.zig index 4fd1354c9..39518e58c 100644 --- a/src/runtime.zig +++ b/src/runtime.zig @@ -31,6 +31,7 @@ pub const Runtime = struct { pub const ActivateFunction = "activate"; }; + // If you change this, remember to update "runtime.footer.js" and rebuild the runtime.js pub const Imports = struct { __name: ?Ref = null, __toModule: ?Ref = null, diff --git a/src/runtime/hmr.ts b/src/runtime/hmr.ts index cef229de2..7260f28a0 100644 --- a/src/runtime/hmr.ts +++ b/src/runtime/hmr.ts @@ -1,858 +1,1140 @@ -import { ByteBuffer } from "peechy/bb"; +import { ByteBuffer } from "peechy"; import * as API from "../api/schema"; -var runOnce = false; -var clientStartTime = 0; +var __HMRModule, __FastRefreshModule, __HMRClient; +// We add a scope here to minimize chances of namespace collisions +{ + var runOnce = false; + var clientStartTime = 0; -function formatDuration(duration: number) { - return Math.round(duration * 1000) / 1000; -} - -type HTMLStylableElement = HTMLLinkElement | HTMLStyleElement; -type CSSHMRInsertionPoint = { - id: number; - node?: HTMLStylableElement; - file: string; - bundle_id: number; - sheet: CSSStyleSheet; -}; - -enum CSSUpdateMethod { - // CSS OM allows synchronous style updates - cssObjectModel, - // Blob URLs allow us to skip converting to JavaScript strings - // However, they run asynchronously. Frequent updates cause FOUC - blobURL, -} + function formatDuration(duration: number) { + return Math.round(duration * 1000) / 1000; + } -// How this works -// We keep -export class CSSLoader { - hmr: HMRClient; - private static cssLoadId: CSSHMRInsertionPoint = { - id: 0, - bundle_id: 0, - node: null, - file: "", - sheet: null, + type HTMLStylableElement = HTMLLinkElement | HTMLStyleElement; + type CSSHMRInsertionPoint = { + id: number; + node?: HTMLStylableElement; + file: string; + bundle_id: number; + sheet: CSSStyleSheet; }; - updateMethod: CSSUpdateMethod; - decoder: TextDecoder; - - constructor() { - if ("replaceSync" in CSSStyleSheet.prototype) { - this.updateMethod = CSSUpdateMethod.cssObjectModel; - } else { - this.updateMethod = CSSUpdateMethod.blobURL; - } + enum CSSUpdateMethod { + // CSS OM allows synchronous style updates + cssObjectModel, + // Blob URLs allow us to skip converting to JavaScript strings + // However, they run asynchronously. Frequent updates cause FOUC + blobURL, } - // This is a separate function because calling a small function 2000 times is more likely to cause it to be JIT'd - // We want it to be JIT'd - // It's possible that returning null may be a de-opt though. - private findMatchingSupportsRule( - rule: CSSSupportsRule, - id: number, - sheet: CSSStyleSheet - ): CSSHMRInsertionPoint | null { - switch (rule.type) { - // 12 is result.SUPPORTS_RULE - case 12: { - if (!rule.conditionText.startsWith("(hmr-wid:")) { - return null; - } - - const startIndex = "hmr-wid:".length + 1; - const endIDRegion = rule.conditionText.indexOf(")", startIndex); - if (endIDRegion === -1) return null; + enum ReloadBehavior { + fullReload, + hotReload, + ignore, + } - const int = parseInt( - rule.conditionText.substring(startIndex, endIDRegion), - 10 - ); + const FastRefreshLoader = { + RefreshRuntime: null, + isUpdateInProgress: false, + hasInjectedFastRefresh: false, - if (int !== id) { - return null; - } + performFullRefresh() { + HMRClient.client.performFullReload(); + }, - let startFileRegion = rule.conditionText.indexOf( - '(hmr-file:"', - endIDRegion - ); - if (startFileRegion === -1) return null; - startFileRegion += '(hmr-file:"'.length + 1; - - const endFileRegion = rule.conditionText.indexOf('"', startFileRegion); - if (endFileRegion === -1) return null; - // Empty file strings are invalid - if (endFileRegion - startFileRegion <= 0) return null; - - CSSLoader.cssLoadId.id = int; - CSSLoader.cssLoadId.node = sheet.ownerNode as HTMLStylableElement; - CSSLoader.cssLoadId.sheet = sheet; - CSSLoader.cssLoadId.file = rule.conditionText.substring( - startFileRegion - 1, - endFileRegion - ); + async hotReload() { + if (FastRefreshLoader.isUpdateInProgress) return; - return CSSLoader.cssLoadId; + try { + FastRefreshLoader.isUpdateInProgress = true; + } finally { + FastRefreshLoader.isUpdateInProgress = false; } - default: { - return null; + }, + }; + + class CSSLoader { + hmr: HMRClient; + private static cssLoadId: CSSHMRInsertionPoint = { + id: 0, + bundle_id: 0, + node: null, + file: "", + sheet: null, + }; + + updateMethod: CSSUpdateMethod; + decoder: TextDecoder; + + constructor() { + if ("replaceSync" in CSSStyleSheet.prototype) { + this.updateMethod = CSSUpdateMethod.cssObjectModel; + } else { + this.updateMethod = CSSUpdateMethod.blobURL; } } - } - - bundleId(): number { - return CSSLoader.cssLoadId.bundle_id; - } - - private findCSSLinkTag(id: number): CSSHMRInsertionPoint | null { - let count = 0; - let match: CSSHMRInsertionPoint = null; - if (this.updateMethod === CSSUpdateMethod.cssObjectModel) { - if (document.adoptedStyleSheets.length > 0) { - count = document.adoptedStyleSheets.length; - - for (let i = 0; i < count && match === null; i++) { - let cssRules: CSSRuleList; - let sheet: CSSStyleSheet; - let ruleCount = 0; - // Non-same origin stylesheets will potentially throw "Security error" - // We will ignore those stylesheets and look at others. - try { - sheet = document.adoptedStyleSheets[i]; - cssRules = sheet.rules; - ruleCount = sheet.rules.length; - } catch (exception) { - continue; + // This is a separate function because calling a small function 2000 times is more likely to cause it to be JIT'd + // We want it to be JIT'd + // It's possible that returning null may be a de-opt though. + private findMatchingSupportsRule( + rule: CSSSupportsRule, + id: number, + sheet: CSSStyleSheet + ): CSSHMRInsertionPoint | null { + switch (rule.type) { + // 12 is result.SUPPORTS_RULE + case 12: { + if (!rule.conditionText.startsWith("(hmr-wid:")) { + return null; } - if (sheet.disabled || sheet.rules.length === 0) { - continue; - } + const startIndex = "hmr-wid:".length + 1; + const endIDRegion = rule.conditionText.indexOf(")", startIndex); + if (endIDRegion === -1) return null; - const bundleIdRule = cssRules[0] as CSSSupportsRule; - if ( - bundleIdRule.type !== 12 || - !bundleIdRule.conditionText.startsWith("(hmr-bid:") - ) { - continue; + const int = parseInt( + rule.conditionText.substring(startIndex, endIDRegion), + 10 + ); + + if (int !== id) { + return null; } - const bundleIdEnd = bundleIdRule.conditionText.indexOf( - ")", - "(hmr-bid:".length + 1 + let startFileRegion = rule.conditionText.indexOf( + '(hmr-file:"', + endIDRegion ); - if (bundleIdEnd === -1) continue; + if (startFileRegion === -1) return null; + startFileRegion += '(hmr-file:"'.length + 1; - CSSLoader.cssLoadId.bundle_id = parseInt( - bundleIdRule.conditionText.substring( - "(hmr-bid:".length, - bundleIdEnd - ), - 10 + const endFileRegion = rule.conditionText.indexOf( + '"', + startFileRegion + ); + if (endFileRegion === -1) return null; + // Empty file strings are invalid + if (endFileRegion - startFileRegion <= 0) return null; + + CSSLoader.cssLoadId.id = int; + CSSLoader.cssLoadId.node = sheet.ownerNode as HTMLStylableElement; + CSSLoader.cssLoadId.sheet = sheet; + CSSLoader.cssLoadId.file = rule.conditionText.substring( + startFileRegion - 1, + endFileRegion ); - for (let j = 1; j < ruleCount && match === null; j++) { - match = this.findMatchingSupportsRule( - cssRules[j] as CSSSupportsRule, - id, - sheet - ); - } + return CSSLoader.cssLoadId; + } + default: { + return null; } } } - count = document.styleSheets.length; + bundleId(): number { + return CSSLoader.cssLoadId.bundle_id; + } - for (let i = 0; i < count && match === null; i++) { - let cssRules: CSSRuleList; - let sheet: CSSStyleSheet; - let ruleCount = 0; - // Non-same origin stylesheets will potentially throw "Security error" - // We will ignore those stylesheets and look at others. - try { - sheet = document.styleSheets.item(i); - cssRules = sheet.rules; - ruleCount = sheet.rules.length; - } catch (exception) { - continue; - } + private findCSSLinkTag(id: number): CSSHMRInsertionPoint | null { + let count = 0; + let match: CSSHMRInsertionPoint = null; + + if (this.updateMethod === CSSUpdateMethod.cssObjectModel) { + if (document.adoptedStyleSheets.length > 0) { + count = document.adoptedStyleSheets.length; + + for (let i = 0; i < count && match === null; i++) { + let cssRules: CSSRuleList; + let sheet: CSSStyleSheet; + let ruleCount = 0; + // Non-same origin stylesheets will potentially throw "Security error" + // We will ignore those stylesheets and look at others. + try { + sheet = document.adoptedStyleSheets[i]; + cssRules = sheet.rules; + ruleCount = sheet.rules.length; + } catch (exception) { + continue; + } + + if (sheet.disabled || sheet.rules.length === 0) { + continue; + } + + const bundleIdRule = cssRules[0] as CSSSupportsRule; + if ( + bundleIdRule.type !== 12 || + !bundleIdRule.conditionText.startsWith("(hmr-bid:") + ) { + continue; + } + + const bundleIdEnd = bundleIdRule.conditionText.indexOf( + ")", + "(hmr-bid:".length + 1 + ); + if (bundleIdEnd === -1) continue; + + CSSLoader.cssLoadId.bundle_id = parseInt( + bundleIdRule.conditionText.substring( + "(hmr-bid:".length, + bundleIdEnd + ), + 10 + ); - if (sheet.disabled || sheet.rules.length === 0) { - continue; + for (let j = 1; j < ruleCount && match === null; j++) { + match = this.findMatchingSupportsRule( + cssRules[j] as CSSSupportsRule, + id, + sheet + ); + } + } + } } - const bundleIdRule = cssRules[0] as CSSSupportsRule; - if ( - bundleIdRule.type !== 12 || - !bundleIdRule.conditionText.startsWith("(hmr-bid:") - ) { - continue; - } + count = document.styleSheets.length; + + for (let i = 0; i < count && match === null; i++) { + let cssRules: CSSRuleList; + let sheet: CSSStyleSheet; + let ruleCount = 0; + // Non-same origin stylesheets will potentially throw "Security error" + // We will ignore those stylesheets and look at others. + try { + sheet = document.styleSheets.item(i); + cssRules = sheet.rules; + ruleCount = sheet.rules.length; + } catch (exception) { + continue; + } - const bundleIdEnd = bundleIdRule.conditionText.indexOf( - ")", - "(hmr-bid:".length + 1 - ); - if (bundleIdEnd === -1) continue; + if (sheet.disabled || sheet.rules.length === 0) { + continue; + } - CSSLoader.cssLoadId.bundle_id = parseInt( - bundleIdRule.conditionText.substring("(hmr-bid:".length, bundleIdEnd), - 10 - ); + const bundleIdRule = cssRules[0] as CSSSupportsRule; + if ( + bundleIdRule.type !== 12 || + !bundleIdRule.conditionText.startsWith("(hmr-bid:") + ) { + continue; + } - for (let j = 1; j < ruleCount && match === null; j++) { - match = this.findMatchingSupportsRule( - cssRules[j] as CSSSupportsRule, - id, - sheet + const bundleIdEnd = bundleIdRule.conditionText.indexOf( + ")", + "(hmr-bid:".length + 1 ); - } - } + if (bundleIdEnd === -1) continue; - // Ensure we don't leak the HTMLLinkElement - if (match === null) { - CSSLoader.cssLoadId.file = ""; - CSSLoader.cssLoadId.bundle_id = CSSLoader.cssLoadId.id = 0; - CSSLoader.cssLoadId.node = null; - CSSLoader.cssLoadId.sheet = null; - } + CSSLoader.cssLoadId.bundle_id = parseInt( + bundleIdRule.conditionText.substring("(hmr-bid:".length, bundleIdEnd), + 10 + ); - return match; - } + for (let j = 1; j < ruleCount && match === null; j++) { + match = this.findMatchingSupportsRule( + cssRules[j] as CSSSupportsRule, + id, + sheet + ); + } + } - handleBuildSuccess( - buffer: ByteBuffer, - build: API.WebsocketMessageBuildSuccess, - timestamp: number - ) { - const start = performance.now(); - var update = this.findCSSLinkTag(build.id); - let bytes = - buffer._data.length > buffer._index - ? buffer._data.subarray(buffer._index) - : new Uint8Array(0); - if (update === null) { - __hmrlog.debug("Skipping unused CSS."); - return; - } + // Ensure we don't leak the HTMLLinkElement + if (match === null) { + CSSLoader.cssLoadId.file = ""; + CSSLoader.cssLoadId.bundle_id = CSSLoader.cssLoadId.id = 0; + CSSLoader.cssLoadId.node = null; + CSSLoader.cssLoadId.sheet = null; + } - if (bytes.length === 0) { - __hmrlog.debug("Skipping empty file"); - return; + return match; } - let filepath = update.file; - const _timestamp = timestamp; - const from_timestamp = build.from_timestamp; - function onLoadHandler() { - const localDuration = formatDuration(performance.now() - start); - const fsDuration = _timestamp - from_timestamp; - __hmrlog.log( - "Reloaded in", - `${localDuration + fsDuration}ms`, - "-", - filepath - ); + handleBuildSuccess( + buffer: ByteBuffer, + build: API.WebsocketMessageBuildSuccess, + timestamp: number + ) { + const start = performance.now(); + var update = this.findCSSLinkTag(build.id); + let bytes = + buffer.data.length > buffer.index + ? buffer.data.subarray(buffer.index) + : new Uint8Array(0); + if (update === null) { + __hmrlog.debug("Skipping unused CSS."); + return; + } - update = null; - filepath = null; - } + if (bytes.length === 0) { + __hmrlog.debug("Skipping empty file"); + return; + } - // Whenever - switch (this.updateMethod) { - case CSSUpdateMethod.blobURL: { - let blob = new Blob([bytes], { type: "text/css" }); - - const blobURL = URL.createObjectURL(blob); - // onLoad doesn't fire in Chrome. - // I'm not sure why. - // Guessing it only triggers when an element is added/removed, not when the href just changes - // So we say on the next tick, we're loaded. - setTimeout(onLoadHandler.bind(update.node), 0); - update.node.setAttribute("href", blobURL); - blob = null; - URL.revokeObjectURL(blobURL); - break; + let filepath = update.file; + const _timestamp = timestamp; + const from_timestamp = build.from_timestamp; + function onLoadHandler() { + const localDuration = formatDuration(performance.now() - start); + const fsDuration = _timestamp - from_timestamp; + __hmrlog.log( + "Reloaded in", + `${localDuration + fsDuration}ms`, + "-", + filepath + ); + + update = null; + filepath = null; } - case CSSUpdateMethod.cssObjectModel: { - if (!this.decoder) { - this.decoder = new TextDecoder("UTF8"); + + // Whenever + switch (this.updateMethod) { + case CSSUpdateMethod.blobURL: { + let blob = new Blob([bytes], { type: "text/css" }); + + const blobURL = URL.createObjectURL(blob); + // onLoad doesn't fire in Chrome. + // I'm not sure why. + // Guessing it only triggers when an element is added/removed, not when the href just changes + // So we say on the next tick, we're loaded. + setTimeout(onLoadHandler.bind(update.node), 0); + update.node.setAttribute("href", blobURL); + blob = null; + URL.revokeObjectURL(blobURL); + break; } + case CSSUpdateMethod.cssObjectModel: { + if (!this.decoder) { + this.decoder = new TextDecoder("UTF8"); + } - // This is an adoptedStyleSheet, call replaceSync and be done with it. - if (!update.node || update.node.tagName === "HTML") { - update.sheet.replaceSync(this.decoder.decode(bytes)); - } else if ( - update.node.tagName === "LINK" || - update.node.tagName === "STYLE" - ) { - // This might cause CSS specifity issues.... - // I'm not 100% sure this is a safe operation - const sheet = new CSSStyleSheet(); - sheet.replaceSync(this.decoder.decode(bytes)); - update.node.remove(); - document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]; + // This is an adoptedStyleSheet, call replaceSync and be done with it. + if (!update.node || update.node.tagName === "HTML") { + update.sheet.replaceSync(this.decoder.decode(bytes)); + } else if ( + update.node.tagName === "LINK" || + update.node.tagName === "STYLE" + ) { + // This might cause CSS specifity issues.... + // I'm not 100% sure this is a safe operation + const sheet = new CSSStyleSheet(); + sheet.replaceSync(this.decoder.decode(bytes)); + update.node.remove(); + document.adoptedStyleSheets = [ + ...document.adoptedStyleSheets, + sheet, + ]; + } + break; } - break; } + + buffer = null; + bytes = null; } - buffer = null; - bytes = null; - } + filePath( + file_change_notification: API.WebsocketMessageFileChangeNotification + ): string | null { + if (file_change_notification.loader !== API.Loader.css) return null; + const tag = this.findCSSLinkTag(file_change_notification.id); + + if (!tag) { + return null; + } - reload(timestamp: number) { - // function onLoadHandler(load: Event) { - // const localDuration = formatDuration(performance.now() - start); - // const fsDuration = _timestamp - from_timestamp; - // __hmrlog.log( - // "Reloaded in", - // `${localDuration + fsDuration}ms`, - // "-", - // filepath - // ); - - // blob = null; - // update = null; - // filepath = null; - - // if (this.href.includes("blob:")) { - // URL.revokeObjectURL(this.href); - // } - // } - - const url = new URL(CSSLoader.cssLoadId.node.href, location.href); - url.searchParams.set("v", timestamp.toString(10)); - CSSLoader.cssLoadId.node.setAttribute("href", url.toString()); + return tag.file; + } } - filePath( - file_change_notification: API.WebsocketMessageFileChangeNotification - ): string | null { - if (file_change_notification.loader !== API.Loader.css) return null; - const tag = this.findCSSLinkTag(file_change_notification.id); + class HMRClient { + static client: HMRClient; + socket: WebSocket; + hasWelcomed: boolean = false; + reconnect: number = 0; + // Server timestamps are relative to the time the server's HTTP server launched + // This so we can send timestamps as uint32 instead of 128-bit integers + epoch: number = 0; + javascriptReloader: API.Reloader = API.Reloader.disable; + loaders = { + css: new CSSLoader(), + }; + start() { + if (runOnce) { + __hmrlog.warn( + "Attempted to start HMR client multiple times. This may be a bug." + ); + return; + } + + this.loaders.css.hmr = this; + runOnce = true; + this.connect(); - if (!tag) { - return null; + // Explicitly send a socket close event so the thread doesn't have to wait for a timeout + var origUnload = globalThis.onunload; + globalThis.onunload = (ev: Event) => { + if (this.socket && this.socket.readyState === this.socket.OPEN) { + this.socket.close(0, "unload"); + } + origUnload && origUnload.call(globalThis, [ev]); + }; } - return tag.file; - } -} + connect() { + clientStartTime = performance.now(); -class HMRClient { - static client: HMRClient; - socket: WebSocket; - hasWelcomed: boolean = false; - reconnect: number = 0; - // Server timestamps are relative to the time the server's HTTP server launched - // This so we can send timestamps as uint32 instead of 128-bit integers - epoch: number = 0; - - loaders = { - css: new CSSLoader(), - }; - start() { - if (runOnce) { - __hmrlog.warn( - "Attempted to start HMR client multiple times. This may be a bug." - ); - return; - } + if (this.reconnect) { + globalThis.clearInterval(this.reconnect); + this.reconnect = 0; + } - this.loaders.css.hmr = this; - runOnce = true; - this.connect(); - } + const baseURL = new URL(location.origin + "/_api"); + baseURL.protocol = location.protocol === "https" ? "wss" : "ws"; + this.socket = new WebSocket(baseURL.toString(), ["speedy-hmr"]); + this.socket.binaryType = "arraybuffer"; + this.socket.onclose = this.handleClose; + this.socket.onerror = this.handleError; + this.socket.onopen = this.handleOpen; + this.socket.onmessage = this.handleMessage; + } - connect() { - clientStartTime = performance.now(); - const baseURL = new URL(location.origin + "/_api"); - baseURL.protocol = location.protocol === "https" ? "wss" : "ws"; - this.socket = new WebSocket(baseURL.toString(), ["speedy-hmr"]); - this.socket.binaryType = "arraybuffer"; - this.socket.onclose = this.handleClose; - this.socket.onerror = this.handleError; - this.socket.onopen = this.handleOpen; - this.socket.onmessage = this.handleMessage; - } + // key: module id + // value: server-timestamp + builds = new Map<number, number>(); - // key: module id - // value: server-timestamp - builds = new Map<number, number>(); + indexOfModuleId(id: number): number { + return HMRModule.dependencies.graph.indexOf(id); + } - indexOfModuleId(id: number): number { - return HMRModule.dependencies.graph.indexOf(id); - } + static activate(verbose: boolean = false) { + // Support browser-like envirnments where location and WebSocket exist + // Maybe it'll work in Deno! Who knows. + if ( + this.client || + typeof location === "undefined" || + typeof WebSocket === "undefined" + ) { + return; + } - static activate(verbose: boolean = false) { - // Support browser-like envirnments where location and WebSocket exist - // Maybe it'll work in Deno! Who knows. - if ( - this.client || - typeof location === "undefined" || - typeof WebSocket === "undefined" - ) { - return; + this.client = new HMRClient(); + this.client.verbose = verbose; + this.client.start(); + globalThis["SPEEDY_HMR"] = this.client; } - this.client = new HMRClient(); - this.client.verbose = verbose; - this.client.start(); - globalThis["SPEEDY_HMR"] = this.client; - } + handleBuildFailure(buffer: ByteBuffer, timestamp: number) { + const build = API.decodeWebsocketMessageBuildFailure(buffer); + const id = build.id; - handleBuildFailure(buffer: ByteBuffer, timestamp: number) { - const build = API.decodeWebsocketMessageBuildFailure(buffer); - const id = build.id; + const index = this.indexOfModuleId(id); + // Ignore build failures of modules that are not loaded + if (index === -1) { + return; + } - const index = this.indexOfModuleId(id); - // Ignore build failures of modules that are not loaded - if (index === -1) { - return; + // Build failed for a module we didn't request? + const minTimestamp = this.builds.get(index); + if (!minTimestamp) { + return; + } + const fail = API.decodeWebsocketMessageBuildFailure(buffer); + // TODO: finish this. + __hmrlog.error("Build failed", fail.module_path); } - // Build failed for a module we didn't request? - const minTimestamp = this.builds.get(index); - if (!minTimestamp) { - return; - } - const fail = API.decodeWebsocketMessageBuildFailure(buffer); - // TODO: finish this. - __hmrlog.error("Build failed", fail.module_path); - } + verbose = false; - verbose = false; + handleError = (error: ErrorEvent) => { + __hmrlog.error("Websocket error", error.error); + if (this.reconnect !== 0) { + return; + } - handleError = (error: ErrorEvent) => { - __hmrlog.error("Websocket error", error.error); - if (this.reconnect !== 0) { - return; - } + this.reconnect = setInterval(this.connect, 500) as any as number; + }; - this.reconnect = setInterval(this.connect, 500) as any as number; - }; + handleBuildSuccess(buffer: ByteBuffer, timestamp: number) { + const build = API.decodeWebsocketMessageBuildSuccess(buffer); + + // Ignore builds of modules we expect a later version of + const currentVersion = this.builds.get(build.id) || -Infinity; - handleBuildSuccess(buffer: ByteBuffer, timestamp: number) { - const build = API.decodeWebsocketMessageBuildSuccess(buffer); + if (currentVersion > build.from_timestamp) { + if (this.verbose) { + __hmrlog.debug( + `Ignoring outdated update for "${build.module_path}".\n Expected: >=`, + currentVersion, + `\n Received:`, + build.from_timestamp + ); + } + return; + } - // Ignore builds of modules we expect a later version of - const currentVersion = this.builds.get(build.id) || -Infinity; + if (build.loader === API.Loader.css) { + return this.loaders.css.handleBuildSuccess(buffer, build, timestamp); + } + + const id = build.id; + const index = this.indexOfModuleId(id); + // Ignore builds of modules that are not loaded + if (index === -1) { + if (this.verbose) { + __hmrlog.debug(`Skipping reload for unknown module id:`, id); + } + + return; + } - if (currentVersion > build.from_timestamp) { if (this.verbose) { __hmrlog.debug( - `Ignoring outdated update for "${build.module_path}".\n Expected: >=`, - currentVersion, - `\n Received:`, - build.from_timestamp + "Preparing to reload", + HMRModule.dependencies.modules[index].file_path ); } - return; + + var reload = new HotReload( + build.id, + index, + build, + // These are the bytes!! + buffer.data.length > buffer.index + ? buffer.data.subarray(buffer.index) + : new Uint8Array(0), + ReloadBehavior.hotReload + ); + reload.timings.notify = timestamp - build.from_timestamp; + reload.run().then( + ([module, timings]) => { + __hmrlog.log( + `Reloaded in ${formatDuration(timings.total)}ms :`, + module.file_path + ); + }, + (err) => { + if ( + typeof err === "object" && + err && + err instanceof ThrottleModuleUpdateError + ) { + return; + } + __hmrlog.error("Hot Module Reload failed!", err); + debugger; + } + ); } - if (build.loader === API.Loader.css) { - return this.loaders.css.handleBuildSuccess(buffer, build, timestamp); + performFullReload() { + if (typeof location !== "undefined") { + if (this.socket.readyState === this.socket.OPEN) { + // Disable reconnecting + this.reconnect = 1; + this.socket.close(); + } + location.reload(); + } } - const id = build.id; - const index = this.indexOfModuleId(id); - // Ignore builds of modules that are not loaded - if (index === -1) { - if (this.verbose) { - __hmrlog.debug(`Skipping reload for unknown module id:`, id); + handleFileChangeNotification(buffer: ByteBuffer, timestamp: number) { + const notification = + API.decodeWebsocketMessageFileChangeNotification(buffer); + let file_path = ""; + switch (notification.loader) { + case API.Loader.css: { + file_path = this.loaders.css.filePath(notification); + break; + } + + default: { + const index = HMRModule.dependencies.graph.indexOf(notification.id); + + if (index > -1) { + file_path = HMRModule.dependencies.modules[index].file_path; + } + break; + } } - return; - } + const accept = file_path && file_path.length > 0; - if (this.verbose) { - __hmrlog.debug( - "Preparing to reload", - HMRModule.dependencies.modules[index].file_path - ); - } + if (!accept) { + if (this.verbose) { + __hmrlog.debug("Unknown module changed, skipping"); + } + return; + } - var reload = new HotReload( - build.id, - index, - build, - // These are the bytes!! - buffer._data.length > buffer._index - ? buffer._data.subarray(buffer._index) - : new Uint8Array(0) - ); - reload.timings.notify = timestamp - build.from_timestamp; - reload.run().then( - ([module, timings]) => { - __hmrlog.log( - `Reloaded in ${formatDuration(timings.total)}ms :`, - module.file_path - ); - }, - (err) => { - __hmrlog.error("Hot Module Reload failed!", err); - debugger; + if ((this.builds.get(notification.id) || -Infinity) > timestamp) { + __hmrlog.debug(`Received stale update for ${file_path}`); + return; } - ); - } - handleFileChangeNotification(buffer: ByteBuffer, timestamp: number) { - const notification = - API.decodeWebsocketMessageFileChangeNotification(buffer); - let file_path = ""; - switch (notification.loader) { - case API.Loader.css: { - file_path = this.loaders.css.filePath(notification); - break; + let reloadBehavior = ReloadBehavior.ignore; + + switch (notification.loader) { + // CSS always supports hot reloading + case API.Loader.css: { + this.builds.set(notification.id, timestamp); + // When we're dealing with CSS, even though the watch event happened for a file in the bundle + // We want it to regenerate the entire bundle + // So we must swap out the ID we send for the ID of the corresponding bundle. + notification.id = this.loaders.css.bundleId(); + this.builds.set(notification.id, timestamp); + reloadBehavior = ReloadBehavior.hotReload; + break; + } + // The backend will detect if they have react-refresh in their bundle + // If so, it will use it. + // Else, it will fall back to live reloading. + case API.Loader.js: + case API.Loader.json: + case API.Loader.ts: + case API.Loader.tsx: { + switch (this.javascriptReloader) { + case API.Reloader.disable: { + break; + } + case API.Reloader.fast_refresh: { + this.builds.set(notification.id, timestamp); + reloadBehavior = ReloadBehavior.hotReload; + break; + } + case API.Reloader.live: { + reloadBehavior = ReloadBehavior.fullReload; + break; + } + } + break; + } } - default: { - const index = HMRModule.dependencies.graph.indexOf(notification.id); + switch (reloadBehavior) { + // This is the same command/logic for both JS and CSS hot reloading. + case ReloadBehavior.hotReload: { + this.buildCommandBuf[0] = API.WebsocketCommandKind.build; + this.buildCommandUArray[0] = timestamp; + this.buildCommandBuf.set(this.buildCommandUArrayEight, 1); + this.buildCommandUArray[0] = notification.id; + this.buildCommandBuf.set(this.buildCommandUArrayEight, 5); + this.socket.send(this.buildCommandBuf); + if (this.verbose) { + __hmrlog.debug(`Requesting update for ${file_path}`); + } + break; + } - if (index > -1) { - file_path = HMRModule.dependencies.modules[index].file_path; + case ReloadBehavior.fullReload: { + this.performFullReload(); + break; } - break; } } + buildCommandBuf = new Uint8Array(9); + buildCommandUArray = new Uint32Array(1); + buildCommandUArrayEight = new Uint8Array(this.buildCommandUArray.buffer); + + handleOpen = (event: Event) => { + globalThis.clearInterval(this.reconnect); + this.reconnect = 0; + }; + + handleMessage = (event: MessageEvent) => { + const data = new Uint8Array(event.data); + const message_header_byte_buffer = new ByteBuffer(data); + const header = API.decodeWebsocketMessage(message_header_byte_buffer); + const buffer = new ByteBuffer( + data.subarray(message_header_byte_buffer.index) + ); - const accept = file_path && file_path.length > 0; + switch (header.kind) { + case API.WebsocketMessageKind.build_fail: { + this.handleBuildFailure(buffer, header.timestamp); + break; + } + case API.WebsocketMessageKind.build_success: { + this.handleBuildSuccess(buffer, header.timestamp); + break; + } - if (!accept) { - if (this.verbose) { - __hmrlog.debug("Unknown module changed, skipping"); + case API.WebsocketMessageKind.file_change_notification: { + this.handleFileChangeNotification(buffer, header.timestamp); + break; + } + case API.WebsocketMessageKind.welcome: { + const now = performance.now(); + __hmrlog.log( + "HMR connected in", + formatDuration(now - clientStartTime), + "ms" + ); + clientStartTime = now; + this.hasWelcomed = true; + const welcome = API.decodeWebsocketMessageWelcome(buffer); + this.epoch = welcome.epoch; + this.javascriptReloader = welcome.javascriptReloader; + if (!this.epoch) { + __hmrlog.warn("Internal HMR error"); + } + break; + } } - return; - } + }; + + handleClose = (event: CloseEvent) => { + if (this.reconnect !== 0) { + return; + } + + this.reconnect = globalThis.setInterval( + this.connect, + 500 + ) as any as number; + __hmrlog.warn("HMR disconnected. Attempting to reconnect."); + }; + } + let pendingUpdateCount = 0; - if ((this.builds.get(notification.id) || -Infinity) > timestamp) { - __hmrlog.debug(`Received update for ${file_path}`); - return; + class ThrottleModuleUpdateError extends Error { + constructor(message) { + super(message); } + } - if (this.verbose) { - __hmrlog.debug(`Requesting update for ${file_path}`); + class HotReload { + module_id: number = 0; + module_index: number = 0; + build: API.WebsocketMessageBuildSuccess; + + timings = { + notify: 0, + decode: 0, + import: 0, + callbacks: 0, + total: 0, + start: 0, + }; + static VERBOSE = false; + bytes: Uint8Array; + reloader: ReloadBehavior; + + constructor( + module_id: HotReload["module_id"], + module_index: HotReload["module_index"], + build: HotReload["build"], + bytes: Uint8Array, + reloader: ReloadBehavior + ) { + this.module_id = module_id; + this.module_index = module_index; + this.build = build; + this.bytes = bytes; + this.reloader = reloader; } - this.builds.set(notification.id, timestamp); + async run() { + pendingUpdateCount++; + let result: [HMRModule, HotReload["timings"]]; + try { + result = await this._run(); + } finally { + pendingUpdateCount--; + } - // When we're dealing with CSS, even though the watch event happened for a file in the bundle - // We want it to regenerate the entire bundle - // So we must swap out the ID we send for the ID of the corresponding bundle. - if (notification.loader === API.Loader.css) { - notification.id = this.loaders.css.bundleId(); - this.builds.set(notification.id, timestamp); + return result; } - this.buildCommandBuf[0] = API.WebsocketCommandKind.build; - this.buildCommandUArray[0] = timestamp; - this.buildCommandBuf.set(this.buildCommandUArrayEight, 1); - this.buildCommandUArray[0] = notification.id; - this.buildCommandBuf.set(this.buildCommandUArrayEight, 5); - this.socket.send(this.buildCommandBuf); - } - buildCommandBuf = new Uint8Array(9); - buildCommandUArray = new Uint32Array(1); - buildCommandUArrayEight = new Uint8Array(this.buildCommandUArray.buffer); + private async _run(): Promise<[HMRModule, HotReload["timings"]]> { + const currentPendingUpdateCount = pendingUpdateCount; - handleOpen = (event: Event) => { - globalThis.clearInterval(this.reconnect); - this.reconnect = 0; - }; + const importStart = performance.now(); + let orig_deps = HMRModule.dependencies; + // we must preserve the updater since that holds references to the real exports. + // this is a fundamental limitation of using esmodules for HMR. + // we cannot export new modules. we can only mutate existing ones. - handleMessage = (event: MessageEvent) => { - const data = new Uint8Array(event.data); - const message_header_byte_buffer = new ByteBuffer(data); - const header = API.decodeWebsocketMessage(message_header_byte_buffer); - const buffer = new ByteBuffer( - data.subarray(message_header_byte_buffer._index) - ); + const oldGraphUsed = HMRModule.dependencies.graph_used; + var oldModule = HMRModule.dependencies.modules[this.module_index]; + HMRModule.dependencies = orig_deps.fork(this.module_index); + var blobURL = null; + try { + const blob = new Blob([this.bytes], { type: "text/javascript" }); + blobURL = URL.createObjectURL(blob); + await import(blobURL); + this.timings.import = performance.now() - importStart; + } catch (exception) { + HMRModule.dependencies = orig_deps; + URL.revokeObjectURL(blobURL); + // Ensure we don't keep the bytes around longer than necessary + this.bytes = null; + oldModule = null; + throw exception; + } - switch (header.kind) { - case API.WebsocketMessageKind.build_fail: { - this.handleBuildFailure(buffer, header.timestamp); - break; + // We didn't import any new modules, so we resume as before. + if (HMRModule.dependencies.graph_used === this.module_index) { + HMRModule.dependencies.graph_used = oldGraphUsed; + } else { + // If we do import a new module, we have to do a full page reload for now } - case API.WebsocketMessageKind.build_success: { - this.handleBuildSuccess(buffer, header.timestamp); - break; + + URL.revokeObjectURL(blobURL); + // Ensure we don't keep the bytes around longer than necessary + this.bytes = null; + + if (HotReload.VERBOSE) { + __hmrlog.debug( + "Re-imported", + HMRModule.dependencies.modules[this.module_index].file_path, + "in", + formatDuration(this.timings.import), + ". Running callbacks" + ); } - case API.WebsocketMessageKind.file_change_notification: { - this.handleFileChangeNotification(buffer, header.timestamp); - break; + const callbacksStart = performance.now(); + const origUpdaters = + HMRModule.dependencies.modules[ + this.module_index + ].additional_updaters.slice(); + try { + switch (this.reloader) { + case ReloadBehavior.hotReload: { + let foundBoundary = false; + + if (oldModule) { + HMRModule.dependencies.modules[ + this.module_index + ].additional_updaters.push(oldModule.update.bind(oldModule)); + } + // -- For generic hot reloading -- + // ES Modules delay execution until all imports are parsed + // They execute depth-first + // If you load N modules and append each module ID to the array, 0 is the *last* unique module imported. + // modules.length - 1 is the first. + // Therefore, to reload all the modules in the correct order, we traverse the graph backwards + // This only works when the graph is up to date. + // If the import order changes, we need to regenerate the entire graph + // Which sounds expensive, until you realize that we are mostly talking about an array that will be typically less than 1024 elements + // Computers can create an array of < 1024 pointer-sized elements in < 1ms easy! + // -- + + // -- For React Fast Refresh -- + // We must find a React Refresh boundary. This is a module that only exports React components. + // If we do not find a React Refresh boundary, we must instead perform a full page reload. + for ( + let i = HMRModule.dependencies.graph_used; + i > this.module_index; + i-- + ) { + let handled = + !HMRModule.dependencies.modules[i].exports.__hmrDisable; + if ( + typeof HMRModule.dependencies.modules[i].dispose === "function" + ) { + HMRModule.dependencies.modules[i].dispose(); + handled = true; + } + if ( + typeof HMRModule.dependencies.modules[i].accept === "function" + ) { + HMRModule.dependencies.modules[i].accept(); + handled = true; + } + + // Automatically re-initialize the dependency + if (!handled) { + HMRModule.dependencies.modules[i].update(); + } + + // If we don't find a boundary, we will need to do a full page load + if ( + (HMRModule.dependencies.modules[i] as FastRefreshModule) + .isRefreshBoundary + ) { + foundBoundary = true; + } + } + + // By the time we get here, it's entirely possible that another update is waiting + // Instead of scheduling it, we are going to just ignore this update. + // But we still need to re-initialize modules regardless because otherwise a dependency may not reload properly + if ( + pendingUpdateCount === currentPendingUpdateCount && + foundBoundary + ) { + FastRefreshLoader.RefreshRuntime.performReactRefresh(); + } else if (pendingUpdateCount === currentPendingUpdateCount) { + FastRefreshLoader.performFullRefresh(); + } else { + return Promise.reject( + new ThrottleModuleUpdateError( + `Expected pendingUpdateCount: ${currentPendingUpdateCount} but received: ${pendingUpdateCount}` + ) + ); + } + break; + } + } + } catch (exception) { + HMRModule.dependencies = orig_deps; + HMRModule.dependencies.modules[this.module_index].additional_updaters = + origUpdaters; + throw exception; } - case API.WebsocketMessageKind.welcome: { - const now = performance.now(); - __hmrlog.log( - "HMR connected in", - formatDuration(now - clientStartTime), + this.timings.callbacks = performance.now() - callbacksStart; + + if (HotReload.VERBOSE) { + __hmrlog.debug( + "Ran callbacks", + HMRModule.dependencies.modules[this.module_index].file_path, + "in", + formatDuration(this.timings.callbacks), "ms" ); - clientStartTime = now; - this.hasWelcomed = true; - const welcome = API.decodeWebsocketMessageWelcome(buffer); - this.epoch = welcome.epoch; - if (!this.epoch) { - __hmrlog.warn("Internal HMR error"); - } - break; } - } - }; - handleClose = (event: CloseEvent) => { - if (this.reconnect !== 0) { - return; + orig_deps = null; + this.timings.total = + this.timings.import + this.timings.callbacks + this.timings.notify; + return Promise.resolve([ + HMRModule.dependencies.modules[this.module_index], + this.timings, + ]); } - - this.reconnect = globalThis.setInterval(this.connect, 500) as any as number; - __hmrlog.warn("HMR disconnected. Attempting to reconnect."); - }; -} - -export { HMRClient as __HMRClient }; - -class HotReload { - module_id: number = 0; - module_index: number = 0; - build: API.WebsocketMessageBuildSuccess; - timings = { - notify: 0, - decode: 0, - import: 0, - callbacks: 0, - total: 0, - start: 0, - }; - static VERBOSE = false; - bytes: Uint8Array; - - constructor( - module_id: HotReload["module_id"], - module_index: HotReload["module_index"], - build: HotReload["build"], - bytes: Uint8Array - ) { - this.module_id = module_id; - this.module_index = module_index; - this.build = build; - this.bytes = bytes; } - async run(): Promise<[HMRModule, HotReload["timings"]]> { - const importStart = performance.now(); - let orig_deps = HMRModule.dependencies; - // we must preserve the updater since that holds references to the real exports. - // this is a fundamental limitation of using esmodules for HMR. - // we cannot export new modules. we can only mutate existing ones. - - HMRModule.dependencies = orig_deps.fork(this.module_index); - var blobURL = null; - try { - const blob = new Blob([this.bytes], { type: "text/javascript" }); - blobURL = URL.createObjectURL(blob); - await import(blobURL); - this.timings.import = performance.now() - importStart; - } catch (exception) { - HMRModule.dependencies = orig_deps; - URL.revokeObjectURL(blobURL); - // Ensure we don't keep the bytes around longer than necessary - this.bytes = null; - throw exception; - } + type AnyHMRModule = HMRModule | FastRefreshModule; + class DependencyGraph { + modules: AnyHMRModule[]; + graph: Uint32Array; + graph_used = 0; - URL.revokeObjectURL(blobURL); - // Ensure we don't keep the bytes around longer than necessary - this.bytes = null; - - if (HotReload.VERBOSE) { - __hmrlog.debug( - "Re-imported", - HMRModule.dependencies.modules[this.module_index].file_path, - "in", - formatDuration(this.timings.import), - ". Running callbacks" - ); + loadDefaults() { + this.modules = new Array<AnyHMRModule>(32); + this.graph = new Uint32Array(32); + this.graph_used = 0; } - const callbacksStart = performance.now(); - try { - // ES Modules delay execution until all imports are parsed - // They execute depth-first - // If you load N modules and append each module ID to the array, 0 is the *last* unique module imported. - // modules.length - 1 is the first. - // Therefore, to reload all the modules in the correct order, we traverse the graph backwards - // This only works when the graph is up to date. - // If the import order changes, we need to regenerate the entire graph - // Which sounds expensive, until you realize that we are mostly talking about an array that will be typically less than 1024 elements - // Computers can create an array of < 1024 pointer-sized elements in < 1ms easy! - for ( - let i = HMRModule.dependencies.graph_used; - i > this.module_index; - i-- - ) { - let handled = !HMRModule.dependencies.modules[i].exports.__hmrDisable; - if (typeof HMRModule.dependencies.modules[i].dispose === "function") { - HMRModule.dependencies.modules[i].dispose(); - handled = true; - } - if (typeof HMRModule.dependencies.modules[i].accept === "function") { - HMRModule.dependencies.modules[i].accept(); - handled = true; - } - if (!handled) { - HMRModule.dependencies.modules[i]._load(); - } - } - } catch (exception) { - HMRModule.dependencies = orig_deps; - throw exception; - } - this.timings.callbacks = performance.now() - callbacksStart; - - if (HotReload.VERBOSE) { - __hmrlog.debug( - "Ran callbacks", - HMRModule.dependencies.modules[this.module_index].file_path, - "in", - formatDuration(this.timings.callbacks), - "ms" - ); + static loadWithDefaults() { + const graph = new DependencyGraph(); + graph.loadDefaults(); + return graph; } - orig_deps = null; - this.timings.total = - this.timings.import + this.timings.callbacks + this.timings.notify; - return Promise.resolve([ - HMRModule.dependencies.modules[this.module_index], - this.timings, - ]); + fork(offset: number) { + const graph = new DependencyGraph(); + graph.modules = this.modules.slice(); + graph.graph_used = offset; + graph.graph = this.graph.slice(); + return graph; + } } -} -class DependencyGraph { - modules: HMRModule[]; - graph: Uint32Array; - graph_used = 0; + class HMRModule { + constructor(id: number, file_path: string) { + this.id = id; + this.file_path = file_path; + + Object.defineProperty(this, "name", { + get() { + return this.file_path; + }, + configurable: false, + enumerable: false, + }); - loadDefaults() { - this.modules = new Array<HMRModule>(32); - this.graph = new Uint32Array(32); - this.graph_used = 0; - } + if (!HMRModule.dependencies) { + HMRModule.dependencies = HMRModule._dependencies; + } - static loadWithDefaults() { - const graph = new DependencyGraph(); - graph.loadDefaults(); - return graph; - } + this.graph_index = HMRModule.dependencies.graph_used++; - fork(offset: number) { - const graph = new DependencyGraph(); - graph.modules = this.modules.slice(); - graph.graph_used = offset > 0 ? offset - 1 : 0; - graph.graph = this.graph.slice(); - return graph; - } -} + // Grow the dependencies graph + if (HMRModule.dependencies.graph.length <= this.graph_index) { + const new_graph = new Uint32Array( + HMRModule.dependencies.graph.length * 4 + ); + new_graph.set(HMRModule.dependencies.graph); + HMRModule.dependencies.graph = new_graph; -class HMRModule { - constructor(id: number, file_path: string) { - this.id = id; - this.file_path = file_path; + // In-place grow. This creates a holey array, which is bad, but less bad than pushing potentially 1000 times + HMRModule.dependencies.modules.length = new_graph.length; + } - if (!HMRModule.dependencies) { - HMRModule.dependencies = HMRModule._dependencies; + HMRModule.dependencies.modules[this.graph_index] = this; + HMRModule.dependencies.graph[this.graph_index] = this.id; } - this.graph_index = HMRModule.dependencies.graph_used++; - - // Grow the dependencies graph - if (HMRModule.dependencies.graph.length <= this.graph_index) { - const new_graph = new Uint32Array( - HMRModule.dependencies.graph.length * 4 - ); - new_graph.set(HMRModule.dependencies.graph); - HMRModule.dependencies.graph = new_graph; + additional_files = []; + additional_updaters = []; + _update: (exports: Object) => void; + update() { + for (let update of this.additional_updaters) { + update(this.exports); + } - // In-place grow. This creates a holey array, which is bad, but less bad than pushing potentially 1000 times - HMRModule.dependencies.modules.length = new_graph.length; + this._update(this.exports); } - if ( - typeof HMRModule.dependencies.modules[this.graph_index] === "object" && - HMRModule.dependencies.modules[this.graph_index] instanceof HMRModule && - HMRModule.dependencies.modules[this.graph_index].id === id && - typeof HMRModule.dependencies.modules[this.graph_index]._update === - "function" - ) { - this.additional_updaters.push( - HMRModule.dependencies.modules[this.graph_index]._update - ); + static _dependencies = DependencyGraph.loadWithDefaults(); + exportAll(object: Object) { + // object[alias] must be a function + for (let alias in object) { + this._exports[alias] = object[alias]; + Object.defineProperty(this.exports, alias, { + get: this._exports[alias], + configurable: true, + enumerable: true, + }); + } } - HMRModule.dependencies.modules[this.graph_index] = this; - HMRModule.dependencies.graph[this.graph_index] = this.id; + static dependencies: DependencyGraph; + file_path: string; + _load = function () {}; + id = 0; + graph_index = 0; + _exports = {}; + exports = {}; } - additional_files = []; - additional_updaters = []; - _update: (exports: Object) => void; - update() { - for (let update of this.additional_updaters) { - update(this.exports); + class FastRefreshModule extends HMRModule { + constructor(id: number, file_path: string, RefreshRuntime: any) { + super(id, file_path); + + // 4,000,000,000 in base36 occupies 7 characters + // file path is probably longer + // small strings are better strings + this.refreshRuntimeBaseID = + (this.file_path.length > 7 ? this.id.toString(36) : this.file_path) + + "/"; + FastRefreshLoader.RefreshRuntime = + FastRefreshLoader.RefreshRuntime || RefreshRuntime; + + if (!FastRefreshLoader.hasInjectedFastRefresh) { + RefreshRuntime.injectIntoGlobalHook(globalThis); + FastRefreshLoader.hasInjectedFastRefresh = true; + } } - this._update(this.exports); - } + refreshRuntimeBaseID: string; + isRefreshBoundary = false; - static _dependencies = DependencyGraph.loadWithDefaults(); - exportAll(object: Object) { - // object[alias] must be a function - for (let alias in object) { - this._exports[alias] = object[alias]; - Object.defineProperty(this.exports, alias, { - get: this._exports[alias], - configurable: true, - enumerable: true, - }); + // $RefreshReg$ + $r_(Component: any, id: string) { + FastRefreshLoader.RefreshRuntime.register( + Component, + this.refreshRuntimeBaseID + id + ); + } + // $RefreshReg$(Component, Component.name || Component.displayName) + $r(Component: any) { + if (!FastRefreshLoader.RefreshRuntime.isLikelyComponentType(Component)) { + return; + } + + this.$r_(Component, Component.name || Component.displayName); + } + + // Auto-register exported React components so we only have to manually register the non-exported ones + // This is what Metro does: https://github.com/facebook/metro/blob/9f2b1210a0f66378dd93e5fcaabc464c86c9e236/packages/metro-runtime/src/polyfills/require.js#L905 + exportAll(object: any) { + super.exportAll(object); + + // One thing I'm unsure of: + // Do we need to register the exports object iself? Is it important for some namespacing thing? + // Metro seems to do that. However, that might be an artifact of CommonJS modules. People do module.exports = SomeReactComponent. + var hasExports = false; + var onlyExportsComponents = true; + for (const key in object) { + if (key === "__esModule") { + continue; + } + + hasExports = true; + + // Everything in here should always be a function + // exportAll({blah: () => blah}) + // If you see exception right here, please file an issue and include the source file in the issue. + const Component = object[key](); + + // Ensure exported React components always have names + // This is for simpler debugging + if ( + Component && + typeof Component === "function" && + !("name" in Component) && + Object.isExtensible(Component) + ) { + const named = { + get() { + return key; + }, + enumerable: false, + configurable: true, + }; + // Ignore any errors if it turns out this was already set as not configurable + try { + // "name" is the official JavaScript way + // "displayName" is the legacy React way + Object.defineProperties(Component, { + name: named, + displayName: named, + }); + } catch (exception) {} + } + + if ( + !FastRefreshLoader.RefreshRuntime.isLikelyComponentType(Component) + ) { + onlyExportsComponents = false; + // We can't stop here because we may have other exports which are components that need to be registered. + continue; + } + + this.$r_(Component, key); + } + + this.isRefreshBoundary = hasExports && onlyExportsComponents; + } + + loaded(_onUpdate) { + this._update = _onUpdate; } } - static dependencies: DependencyGraph; - file_path: string; - _load = function () {}; - id = 0; - graph_index = 0; - _exports = {}; - exports = {}; + var __hmrlog = { + debug(...args) { + // console.debug("[speedy]", ...args); + console.debug(...args); + }, + error(...args) { + // console.error("[speedy]", ...args); + console.error(...args); + }, + log(...args) { + // console.log("[speedy]", ...args); + console.log(...args); + }, + warn(...args) { + // console.warn("[speedy]", ...args); + console.warn(...args); + }, + }; + + __HMRModule = HMRModule; + __FastRefreshModule = FastRefreshModule; + __HMRClient = HMRClient; } -var __hmrlog = { - debug(...args) { - // console.debug("[speedy]", ...args); - console.debug(...args); - }, - error(...args) { - // console.error("[speedy]", ...args); - console.error(...args); - }, - log(...args) { - // console.log("[speedy]", ...args); - console.log(...args); - }, - warn(...args) { - // console.warn("[speedy]", ...args); - console.warn(...args); - }, -}; - -export { HMRModule as __HMRModule }; +export { __HMRModule, __FastRefreshModule, __HMRClient }; |