From 1910aeaa9eaf4c8af11f13c907ee9f5ffaf12775 Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Wed, 13 May 2026 21:24:39 +0800 Subject: [PATCH 1/4] fix(template-webpack-plugin): make getLynxTemplatePluginHooks a cross-module singleton When two physical copies of `@lynx-js/template-webpack-plugin` end up in node_modules at distinct paths (e.g. npm hoist conflict that keeps an older version pinned at the top by a peer dep range, while nested copies of the current version live under different parents), Node's ESM loader treats them as separate module instances. The module-scoped `LynxTemplatePluginHooksMap` WeakMap is then duplicated per instance: taps registered through one copy of `LynxEncodePlugin` are invisible to `hooks.encode.promise(...)` invoked through another copy of `LynxTemplatePlugin`. The bail hook resolves with `undefined`, surfacing as Cannot destructure property 'buffer' of '(intermediate value)' as it is undefined. Route storage through a `Symbol.for(...)` key stashed on the `compilation` itself. `Symbol.for` returns the same symbol across module instances in a realm, and the storage lives on the compilation (of which there is exactly one), so any copy of the plugin resolves the same hooks for a given compilation. pnpm installs were unaffected because their symlinked layout already collapses multiple references to a single realpath, but the source should not rely on the package manager's deduplication for correctness. Adds a regression test (`test/get-hooks-singleton.test.ts`) that exercises the cross-instance contract directly through the shared symbol key. --- .changeset/template-plugin-hooks-singleton.md | 5 ++ .../src/LynxTemplatePlugin.ts | 21 +++-- .../test/get-hooks-singleton.test.ts | 83 +++++++++++++++++++ 3 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 .changeset/template-plugin-hooks-singleton.md create mode 100644 packages/webpack/template-webpack-plugin/test/get-hooks-singleton.test.ts diff --git a/.changeset/template-plugin-hooks-singleton.md b/.changeset/template-plugin-hooks-singleton.md new file mode 100644 index 0000000000..e343a2986d --- /dev/null +++ b/.changeset/template-plugin-hooks-singleton.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/template-webpack-plugin": patch +--- + +Fix `LynxTemplatePlugin.getLynxTemplatePluginHooks` returning per-module-instance hooks when this package is loaded from multiple physical paths under `node_modules` (e.g. npm hoist conflicts that nest two copies). Hooks storage is now keyed by `Symbol.for(...)` on the `compilation` itself, so any copy of the plugin resolves to the same hooks for a given compilation. This eliminates the "`Cannot destructure property 'buffer' of '(intermediate value)' as it is undefined`" build error that occurred when `LynxEncodePlugin` registered taps via one module instance while `LynxTemplatePlugin` awaited `hooks.encode.promise(...)` on another. diff --git a/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts b/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts index fa8dcf9df3..2f82f113ff 100644 --- a/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts +++ b/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts @@ -56,7 +56,15 @@ export interface EncodeOptions { [k: string]: unknown; } -const LynxTemplatePluginHooksMap = new WeakMap(); +// Stash hooks on the Compilation itself, keyed by a `Symbol.for` so that multiple +// module instances of this package (e.g. when a non-pnpm install nests two copies +// in node_modules and ESM treats them as distinct modules) all read/write the same +// hooks for a given compilation. A module-scoped WeakMap would have one copy per +// module instance, causing taps registered through one copy to be invisible to +// `encode.promise()` invoked through another. +const LYNX_TEMPLATE_HOOKS_KEY: unique symbol = Symbol.for( + '@lynx-js/template-webpack-plugin/hooks', +) as never; /** * To allow other plugins to alter the Template, this plugin executes @@ -352,13 +360,14 @@ export class LynxTemplatePlugin { * Returns all public hooks of the Lynx template webpack plugin for the given compilation */ static getLynxTemplatePluginHooks(compilation: Compilation): TemplateHooks { - let hooks = LynxTemplatePluginHooksMap.get(compilation); + const stash = compilation as unknown as { + [LYNX_TEMPLATE_HOOKS_KEY]?: TemplateHooks; + }; + let hooks = stash[LYNX_TEMPLATE_HOOKS_KEY]; // Setup the hooks only once if (hooks === undefined) { - LynxTemplatePluginHooksMap.set( - compilation, - hooks = createLynxTemplatePluginHooks(), - ); + hooks = createLynxTemplatePluginHooks(); + stash[LYNX_TEMPLATE_HOOKS_KEY] = hooks; } return hooks; } diff --git a/packages/webpack/template-webpack-plugin/test/get-hooks-singleton.test.ts b/packages/webpack/template-webpack-plugin/test/get-hooks-singleton.test.ts new file mode 100644 index 0000000000..e43cb8088b --- /dev/null +++ b/packages/webpack/template-webpack-plugin/test/get-hooks-singleton.test.ts @@ -0,0 +1,83 @@ +// Copyright 2024 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { describe, expect, test } from '@rstest/core'; + +import { LynxTemplatePlugin } from '../src/index.js'; + +// When two copies of `@lynx-js/template-webpack-plugin` end up at distinct +// physical paths under `node_modules` (npm hoist conflict, file:/link: +// nesting, etc.), Node's ESM loader treats them as two module instances. +// A module-scoped `WeakMap` stash would then double up: taps registered +// through one copy of `LynxTemplatePlugin` are invisible to +// `hooks.encode.promise()` invoked through the other copy, and the bail +// hook resolves with `undefined` — which downstream code destructures. +// +// The fix routes storage through a `Symbol.for(...)` key stashed on the +// `compilation` itself. The same key resolves to the same symbol across +// all module instances in the realm, so any copy of the plugin sees the +// same hooks for a given compilation. These tests assert that contract. +describe('LynxTemplatePlugin.getLynxTemplatePluginHooks - cross-module singleton', () => { + const SHARED_KEY = Symbol.for('@lynx-js/template-webpack-plugin/hooks'); + + test('returns the same hooks for the same compilation across calls', () => { + const compilation = {} as never; + const a = LynxTemplatePlugin.getLynxTemplatePluginHooks(compilation); + const b = LynxTemplatePlugin.getLynxTemplatePluginHooks(compilation); + expect(b).toBe(a); + }); + + test('returns distinct hooks for distinct compilations', () => { + const compilationA = {} as never; + const compilationB = {} as never; + const a = LynxTemplatePlugin.getLynxTemplatePluginHooks(compilationA); + const b = LynxTemplatePlugin.getLynxTemplatePluginHooks(compilationB); + expect(a).not.toBe(b); + }); + + test('hooks created by one module instance are visible to another via Symbol.for key', () => { + // Simulate a second module instance of `@lynx-js/template-webpack-plugin` + // (as would happen when npm puts two copies at different physical paths + // under node_modules). The second instance would have its own module-level + // storage; the only reliable cross-instance shared state is `Symbol.for`. + const compilation = {} as Record; + const hooksFromRealInstance = LynxTemplatePlugin + .getLynxTemplatePluginHooks(compilation as never); + + // A second module instance, given the same compilation, should resolve + // the same hooks via the well-known Symbol.for slot. + const hooksFromSecondInstance = compilation[SHARED_KEY]; + expect(hooksFromSecondInstance).toBe(hooksFromRealInstance); + }); + + test('a tap registered through any instance is observed when encode.promise is awaited through another', async () => { + // Build a fresh compilation. First "instance" registers a tap on encode. + const compilation = {} as Record; + const hooksA = LynxTemplatePlugin + .getLynxTemplatePluginHooks(compilation as never); + + const sentinel = { + buffer: Buffer.from('ok'), + debugInfo: '', + cssDiagnostics: '', + }; + hooksA.encode.tapPromise( + { name: 'tap-from-instance-A', stage: 0 }, + async () => sentinel, + ); + + // Second "instance" resolves hooks from the same compilation. Because + // storage is keyed by `Symbol.for` on the compilation, both instances see + // the identical hooks — the tap from A is reachable from B. + const hooksB = compilation[SHARED_KEY] as typeof hooksA; + expect(hooksB).toBe(hooksA); + + const result = await hooksB.encode.promise({ + // The bail handler returns immediately on the sentinel, so the runtime + // shape of these arguments is irrelevant. + encodeOptions: {} as never, + intermediate: '.rspeedy', + }); + expect(result).toBe(sentinel); + }); +}); From 439c768957336b86a07988a2387252ac9be3dbba Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Wed, 13 May 2026 22:18:55 +0800 Subject: [PATCH 2/4] style(template-webpack-plugin): trim comments on hooks singleton fix --- .../src/LynxTemplatePlugin.ts | 8 ++--- .../test/get-hooks-singleton.test.ts | 35 ++++--------------- 2 files changed, 8 insertions(+), 35 deletions(-) diff --git a/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts b/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts index 2f82f113ff..e1f60563ec 100644 --- a/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts +++ b/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts @@ -56,12 +56,8 @@ export interface EncodeOptions { [k: string]: unknown; } -// Stash hooks on the Compilation itself, keyed by a `Symbol.for` so that multiple -// module instances of this package (e.g. when a non-pnpm install nests two copies -// in node_modules and ESM treats them as distinct modules) all read/write the same -// hooks for a given compilation. A module-scoped WeakMap would have one copy per -// module instance, causing taps registered through one copy to be invisible to -// `encode.promise()` invoked through another. +// Use `Symbol.for` so duplicate copies of this module (e.g. from an npm hoist +// conflict that nests two copies under node_modules) share the same hooks slot. const LYNX_TEMPLATE_HOOKS_KEY: unique symbol = Symbol.for( '@lynx-js/template-webpack-plugin/hooks', ) as never; diff --git a/packages/webpack/template-webpack-plugin/test/get-hooks-singleton.test.ts b/packages/webpack/template-webpack-plugin/test/get-hooks-singleton.test.ts index e43cb8088b..ba35a0f5ba 100644 --- a/packages/webpack/template-webpack-plugin/test/get-hooks-singleton.test.ts +++ b/packages/webpack/template-webpack-plugin/test/get-hooks-singleton.test.ts @@ -5,18 +5,6 @@ import { describe, expect, test } from '@rstest/core'; import { LynxTemplatePlugin } from '../src/index.js'; -// When two copies of `@lynx-js/template-webpack-plugin` end up at distinct -// physical paths under `node_modules` (npm hoist conflict, file:/link: -// nesting, etc.), Node's ESM loader treats them as two module instances. -// A module-scoped `WeakMap` stash would then double up: taps registered -// through one copy of `LynxTemplatePlugin` are invisible to -// `hooks.encode.promise()` invoked through the other copy, and the bail -// hook resolves with `undefined` — which downstream code destructures. -// -// The fix routes storage through a `Symbol.for(...)` key stashed on the -// `compilation` itself. The same key resolves to the same symbol across -// all module instances in the realm, so any copy of the plugin sees the -// same hooks for a given compilation. These tests assert that contract. describe('LynxTemplatePlugin.getLynxTemplatePluginHooks - cross-module singleton', () => { const SHARED_KEY = Symbol.for('@lynx-js/template-webpack-plugin/hooks'); @@ -35,23 +23,17 @@ describe('LynxTemplatePlugin.getLynxTemplatePluginHooks - cross-module singleton expect(a).not.toBe(b); }); - test('hooks created by one module instance are visible to another via Symbol.for key', () => { - // Simulate a second module instance of `@lynx-js/template-webpack-plugin` - // (as would happen when npm puts two copies at different physical paths - // under node_modules). The second instance would have its own module-level - // storage; the only reliable cross-instance shared state is `Symbol.for`. + // A second physical copy of this module would have its own module-level + // storage; the shared `Symbol.for` slot on the compilation is what makes + // them converge on the same hooks. + test('hooks are reachable through the shared Symbol.for slot', () => { const compilation = {} as Record; const hooksFromRealInstance = LynxTemplatePlugin .getLynxTemplatePluginHooks(compilation as never); - - // A second module instance, given the same compilation, should resolve - // the same hooks via the well-known Symbol.for slot. - const hooksFromSecondInstance = compilation[SHARED_KEY]; - expect(hooksFromSecondInstance).toBe(hooksFromRealInstance); + expect(compilation[SHARED_KEY]).toBe(hooksFromRealInstance); }); - test('a tap registered through any instance is observed when encode.promise is awaited through another', async () => { - // Build a fresh compilation. First "instance" registers a tap on encode. + test('a tap registered through one instance fires when encode.promise is awaited through another', async () => { const compilation = {} as Record; const hooksA = LynxTemplatePlugin .getLynxTemplatePluginHooks(compilation as never); @@ -66,15 +48,10 @@ describe('LynxTemplatePlugin.getLynxTemplatePluginHooks - cross-module singleton async () => sentinel, ); - // Second "instance" resolves hooks from the same compilation. Because - // storage is keyed by `Symbol.for` on the compilation, both instances see - // the identical hooks — the tap from A is reachable from B. const hooksB = compilation[SHARED_KEY] as typeof hooksA; expect(hooksB).toBe(hooksA); const result = await hooksB.encode.promise({ - // The bail handler returns immediately on the sentinel, so the runtime - // shape of these arguments is irrelevant. encodeOptions: {} as never, intermediate: '.rspeedy', }); From 47f5094b0fd6c72348046f97afc72c0bbd44368b Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Thu, 14 May 2026 10:58:49 +0800 Subject: [PATCH 3/4] docs(changeset): shorten template-plugin-hooks-singleton entry --- .changeset/template-plugin-hooks-singleton.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/template-plugin-hooks-singleton.md b/.changeset/template-plugin-hooks-singleton.md index e343a2986d..29722ec045 100644 --- a/.changeset/template-plugin-hooks-singleton.md +++ b/.changeset/template-plugin-hooks-singleton.md @@ -2,4 +2,4 @@ "@lynx-js/template-webpack-plugin": patch --- -Fix `LynxTemplatePlugin.getLynxTemplatePluginHooks` returning per-module-instance hooks when this package is loaded from multiple physical paths under `node_modules` (e.g. npm hoist conflicts that nest two copies). Hooks storage is now keyed by `Symbol.for(...)` on the `compilation` itself, so any copy of the plugin resolves to the same hooks for a given compilation. This eliminates the "`Cannot destructure property 'buffer' of '(intermediate value)' as it is undefined`" build error that occurred when `LynxEncodePlugin` registered taps via one module instance while `LynxTemplatePlugin` awaited `hooks.encode.promise(...)` on another. +Make `LynxTemplatePlugin.getLynxTemplatePluginHooks` a cross-module singleton so duplicate copies of this package (e.g. from npm hoist conflicts) share the same hooks per compilation. From 387bc345a1e19407da37d176b359acb8cf531a4d Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Thu, 14 May 2026 11:05:22 +0800 Subject: [PATCH 4/4] test(template-webpack-plugin): exercise true cross-module-instance via fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier singleton tests imported `LynxTemplatePlugin` from a single location and only reread the Symbol.for slot the same instance had just written, so they did not actually exercise two separate module instances. Add a fixture (`fixtures/template-plugin-second-instance.ts`) that re-implements `getLynxTemplatePluginHooks` end-to-end with its own `createHooks` and module-level state. Being a distinct file, Node's ESM loader treats it as a separate module instance — the same condition that arises when npm hoist conflicts place two physical copies of this package under node_modules. The new tests verify both write/read directions and one tap/await path, and were confirmed to fail against the pre-fix `WeakMap` implementation: the third test produces the exact `encode.promise()` resolves with `undefined` symptom from the original bug report. --- .../template-plugin-second-instance.ts | 49 +++++++++++++++++++ .../test/get-hooks-singleton.test.ts | 39 ++++++++------- 2 files changed, 71 insertions(+), 17 deletions(-) create mode 100644 packages/webpack/template-webpack-plugin/test/fixtures/template-plugin-second-instance.ts diff --git a/packages/webpack/template-webpack-plugin/test/fixtures/template-plugin-second-instance.ts b/packages/webpack/template-webpack-plugin/test/fixtures/template-plugin-second-instance.ts new file mode 100644 index 0000000000..c22dc7bf06 --- /dev/null +++ b/packages/webpack/template-webpack-plugin/test/fixtures/template-plugin-second-instance.ts @@ -0,0 +1,49 @@ +// Copyright 2024 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +// Mimics a second physical copy of `@lynx-js/template-webpack-plugin` that +// would land at a separate node_modules path under npm hoist conflicts. It +// re-implements `getLynxTemplatePluginHooks` end-to-end so that *this* module +// instance has its own `createHooks` and its own module-level state — any +// successful cross-instance sharing must therefore happen through whatever +// shared slot the real implementation uses on `compilation`, not through +// accidental code identity. +import { + AsyncSeriesBailHook, + AsyncSeriesWaterfallHook, + SyncWaterfallHook, +} from '@rspack/lite-tapable'; + +const LYNX_TEMPLATE_HOOKS_KEY = Symbol.for( + '@lynx-js/template-webpack-plugin/hooks', +); + +interface MinimalHooks { + asyncChunkName: SyncWaterfallHook; + beforeEncode: AsyncSeriesWaterfallHook; + encode: AsyncSeriesBailHook; + beforeEmit: AsyncSeriesWaterfallHook; + afterEmit: AsyncSeriesWaterfallHook; +} + +function createHooks(): MinimalHooks { + return { + asyncChunkName: new SyncWaterfallHook(['pluginArgs']), + beforeEncode: new AsyncSeriesWaterfallHook(['pluginArgs']), + encode: new AsyncSeriesBailHook(['pluginArgs']), + beforeEmit: new AsyncSeriesWaterfallHook(['pluginArgs']), + afterEmit: new AsyncSeriesWaterfallHook(['pluginArgs']), + }; +} + +export function getHooksFromSecondInstance( + compilation: Record, +): MinimalHooks { + let hooks = compilation[LYNX_TEMPLATE_HOOKS_KEY] as MinimalHooks | undefined; + if (hooks === undefined) { + hooks = createHooks(); + compilation[LYNX_TEMPLATE_HOOKS_KEY] = hooks; + } + return hooks; +} diff --git a/packages/webpack/template-webpack-plugin/test/get-hooks-singleton.test.ts b/packages/webpack/template-webpack-plugin/test/get-hooks-singleton.test.ts index ba35a0f5ba..e61a6ae5e2 100644 --- a/packages/webpack/template-webpack-plugin/test/get-hooks-singleton.test.ts +++ b/packages/webpack/template-webpack-plugin/test/get-hooks-singleton.test.ts @@ -4,10 +4,9 @@ import { describe, expect, test } from '@rstest/core'; import { LynxTemplatePlugin } from '../src/index.js'; +import { getHooksFromSecondInstance } from './fixtures/template-plugin-second-instance.js'; describe('LynxTemplatePlugin.getLynxTemplatePluginHooks - cross-module singleton', () => { - const SHARED_KEY = Symbol.for('@lynx-js/template-webpack-plugin/hooks'); - test('returns the same hooks for the same compilation across calls', () => { const compilation = {} as never; const a = LynxTemplatePlugin.getLynxTemplatePluginHooks(compilation); @@ -23,19 +22,25 @@ describe('LynxTemplatePlugin.getLynxTemplatePluginHooks - cross-module singleton expect(a).not.toBe(b); }); - // A second physical copy of this module would have its own module-level - // storage; the shared `Symbol.for` slot on the compilation is what makes - // them converge on the same hooks. - test('hooks are reachable through the shared Symbol.for slot', () => { + test('real instance writes first, second physical copy reads the same hooks', () => { + const compilation = {} as Record; + const hooksReal = LynxTemplatePlugin + .getLynxTemplatePluginHooks(compilation as never); + const hooksSecond = getHooksFromSecondInstance(compilation); + expect(hooksSecond).toBe(hooksReal); + }); + + test('second physical copy writes first, real instance reads the same hooks', () => { const compilation = {} as Record; - const hooksFromRealInstance = LynxTemplatePlugin + const hooksSecond = getHooksFromSecondInstance(compilation); + const hooksReal = LynxTemplatePlugin .getLynxTemplatePluginHooks(compilation as never); - expect(compilation[SHARED_KEY]).toBe(hooksFromRealInstance); + expect(hooksReal).toBe(hooksSecond); }); - test('a tap registered through one instance fires when encode.promise is awaited through another', async () => { + test('a tap registered through one copy fires when encode.promise is awaited through the other', async () => { const compilation = {} as Record; - const hooksA = LynxTemplatePlugin + const hooksReal = LynxTemplatePlugin .getLynxTemplatePluginHooks(compilation as never); const sentinel = { @@ -43,16 +48,16 @@ describe('LynxTemplatePlugin.getLynxTemplatePluginHooks - cross-module singleton debugInfo: '', cssDiagnostics: '', }; - hooksA.encode.tapPromise( - { name: 'tap-from-instance-A', stage: 0 }, + hooksReal.encode.tapPromise( + { name: 'tap-from-real-instance', stage: 0 }, async () => sentinel, ); - const hooksB = compilation[SHARED_KEY] as typeof hooksA; - expect(hooksB).toBe(hooksA); - - const result = await hooksB.encode.promise({ - encodeOptions: {} as never, + const hooksSecond = getHooksFromSecondInstance(compilation); + const result = await (hooksSecond.encode as unknown as { + promise: (args: unknown) => Promise; + }).promise({ + encodeOptions: {}, intermediate: '.rspeedy', }); expect(result).toBe(sentinel);