From e70e31f172951c3704e00c11e145acab76a35c19 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:49:08 +0000 Subject: [PATCH 1/2] fix: hand out a recorded hash update as buffers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CachedSource.updateHash records what the wrapped source hashed so later calls replay it. Since 3.5.2 a string-backed source hashes its string, so the recording keeps that string, and getCachedData() handed it to callers that serialize it — webpack's pack warns "Serializing big strings" and pays for it on every deserialization. Encode the strings as buffers in getCachedData(), the boundary a caller serializes from, so the recording still holds the source's own string and nothing copies it until it is written out. Hashing reads a string as utf8, so the restored update hashes to the same digest. --- .changeset/cached-hash-update-buffer.md | 5 +++++ lib/CachedSource.js | 22 ++++++++++++++++++++- test/CachedSource.js | 26 +++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 .changeset/cached-hash-update-buffer.md diff --git a/.changeset/cached-hash-update-buffer.md b/.changeset/cached-hash-update-buffer.md new file mode 100644 index 0000000..b201f52 --- /dev/null +++ b/.changeset/cached-hash-update-buffer.md @@ -0,0 +1,5 @@ +--- +"webpack-sources": patch +--- + +Hand out a recorded hash update as buffers, so cached data carries no big string. diff --git a/lib/CachedSource.js b/lib/CachedSource.js index 88c3e84..7ac6454 100644 --- a/lib/CachedSource.js +++ b/lib/CachedSource.js @@ -56,6 +56,26 @@ const mapToBufferedMap = (map) => { return bufferedMap; }; +/** + * Encodes every string a recorded hash update holds as a buffer, so cached data + * handed to a caller carries no big string (they deserialize far slower). + * Hashing a string reads it as utf8, so the encoded update hashes the same. + * @param {(string | Buffer)[]=} update recorded hash update + * @returns {(string | Buffer)[]=} update holding buffers in place of strings + */ +const bufferHashUpdate = (update) => { + if (update === undefined) return undefined; + let index = 0; + while (index < update.length && typeof update[index] !== "string") index++; + if (index === update.length) return update; + const buffered = [...update]; + for (; index < buffered.length; index++) { + const item = buffered[index]; + if (typeof item === "string") buffered[index] = Buffer.from(item, "utf8"); + } + return buffered; +}; + /** * @param {null | BufferedMap} bufferedMap buffered map * @returns {null | RawSourceMap} map @@ -209,7 +229,7 @@ class CachedSource extends Source { : undefined, size: this._cachedSize, maps: bufferedMaps, - hash: this._cachedHashUpdate, + hash: bufferHashUpdate(this._cachedHashUpdate), }; } diff --git a/test/CachedSource.js b/test/CachedSource.js index b92d892..0c866ef 100644 --- a/test/CachedSource.js +++ b/test/CachedSource.js @@ -637,4 +637,30 @@ describe.each([ expect(clone.buffer()).toEqual(source.buffer()); expect(clone.size()).toEqual(source.size()); }); + + it("should hand out a recorded hash update without a big string", () => { + const big = "a".repeat(200000); + const source = new CachedSource(new OriginalSource(big, "big.js")); + source.updateHash(crypto.createHash("md5")); + + const cachedData = source.getCachedData(); + expect(cachedData.hash).toBeDefined(); + for (const item of /** @type {(string | Buffer)[]} */ (cachedData.hash)) { + expect(typeof item).not.toBe("string"); + } + + // the recording itself keeps the source's own string, uncopied + const internal = /** @type {{ _cachedHashUpdate: unknown[] }} */ ( + /** @type {unknown} */ (source) + ); + expect(internal._cachedHashUpdate).toContain(big); + + // @ts-expect-error for tests + const clone = new CachedSource(null, cachedData); + const cloneHash = crypto.createHash("md5"); + clone.updateHash(cloneHash); + const sourceHash = crypto.createHash("md5"); + source.updateHash(sourceHash); + expect(cloneHash.digest("hex")).toBe(sourceHash.digest("hex")); + }); }); From 9e4788509fd0e5c0476a680684e0a1b4b3daf4a6 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:14:01 +0000 Subject: [PATCH 2/2] Encode the recorded hash update once, in place The first version rebuilt the buffered array on every getCachedData() call, so a source serialized more than once was re-encoded each time: +19.7MB of buffer allocation over the css/large fixture's cache build. Encode in place instead. A source nobody asks cached data of still keeps its own string uncopied, and one that is handed out repeatedly is encoded once. --- lib/CachedSource.js | 21 ++++++++++----------- test/CachedSource.js | 14 +++++++++----- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/lib/CachedSource.js b/lib/CachedSource.js index 7ac6454..1fd0fd2 100644 --- a/lib/CachedSource.js +++ b/lib/CachedSource.js @@ -58,22 +58,21 @@ const mapToBufferedMap = (map) => { /** * Encodes every string a recorded hash update holds as a buffer, so cached data - * handed to a caller carries no big string (they deserialize far slower). - * Hashing a string reads it as utf8, so the encoded update hashes the same. + * handed to a caller carries no big string: a string goes into the serialized + * payload, where it is copied in and rebuilt on every deserialization, while a + * buffer is carried alongside it. Hashing reads a string as utf8, so the + * encoded update hashes the same. It encodes in place, so a source handed out + * repeatedly is encoded once, and one that never is keeps its string uncopied. * @param {(string | Buffer)[]=} update recorded hash update - * @returns {(string | Buffer)[]=} update holding buffers in place of strings + * @returns {(string | Buffer)[]=} the update, holding no string */ const bufferHashUpdate = (update) => { if (update === undefined) return undefined; - let index = 0; - while (index < update.length && typeof update[index] !== "string") index++; - if (index === update.length) return update; - const buffered = [...update]; - for (; index < buffered.length; index++) { - const item = buffered[index]; - if (typeof item === "string") buffered[index] = Buffer.from(item, "utf8"); + for (let index = 0; index < update.length; index++) { + const item = update[index]; + if (typeof item === "string") update[index] = Buffer.from(item, "utf8"); } - return buffered; + return update; }; /** diff --git a/test/CachedSource.js b/test/CachedSource.js index 0c866ef..fdc44ea 100644 --- a/test/CachedSource.js +++ b/test/CachedSource.js @@ -641,19 +641,23 @@ describe.each([ it("should hand out a recorded hash update without a big string", () => { const big = "a".repeat(200000); const source = new CachedSource(new OriginalSource(big, "big.js")); + const internal = /** @type {{ _cachedHashUpdate: unknown[] }} */ ( + /** @type {unknown} */ (source) + ); + source.updateHash(crypto.createHash("md5")); + // a source nobody asks cached data of keeps its own string, uncopied + expect(internal._cachedHashUpdate).toContain(big); + const cachedData = source.getCachedData(); expect(cachedData.hash).toBeDefined(); for (const item of /** @type {(string | Buffer)[]} */ (cachedData.hash)) { expect(typeof item).not.toBe("string"); } - // the recording itself keeps the source's own string, uncopied - const internal = /** @type {{ _cachedHashUpdate: unknown[] }} */ ( - /** @type {unknown} */ (source) - ); - expect(internal._cachedHashUpdate).toContain(big); + // asking again encodes nothing: the recording already holds buffers + expect(source.getCachedData().hash).toBe(cachedData.hash); // @ts-expect-error for tests const clone = new CachedSource(null, cachedData);