Repository: web-infra-dev/rsbuild (TypeScript)

Issue:
[Bug]: html.inject false not works 
### Version

```sh
rsbuild 0.2.5
```


### Details

 I set inject false and use custom template. expect script only inject once, but actually inject twice

rsbuildConfig: 
```ts
    rsbuildConfig: {
      html: {
        inject: false,
        template: './static/index.html',
      },
    },
```

template:

```html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>custom template</title>
    <%= htmlWebpackPlugin.tags.headTags %>
    <script src="//example.com/assets/a.js"></script>
  </head>
  <body>
    <div id="test-template">xxx</div>
    <div id="root" />
    <%= htmlWebpackPlugin.tags.bodyTags %>
  </body>
</html>
```


![20231218-151553](https://github.com/web-infra-dev/rsbuild/assets/22373761/4af18b42-36be-492d-a7f0-b6081a03d5e7)


### Reproduce link

https://github.com/web-infra-dev/rsbuild/blob/0759599e6d280b72a4a7bbc293be4c15b9a5316c/e2e/cases/html/inject/index.test.ts

### Reproduce Steps

- cd e2e
- pnpm test:rspack html/inject
- view dist/index.html content


Discussion: confirmed. would you please assign to me if there's no one working on it at the moment.
> confirmed. would you please assign to me if there's no one working on it at the moment.

@xc2 Of course, thanks for your willingness to fix this~

will fix by today(+0800).
ready for review, please check



Fix (patch):
diff --git a/packages/core/src/plugins/html.ts b/packages/core/src/plugins/html.ts
index a41f49f968..0fe7eeb821 100644
--- a/packages/core/src/plugins/html.ts
+++ b/packages/core/src/plugins/html.ts
@@ -11,6 +11,7 @@ import {
   isHtmlDisabled,
   removeTailSlash,
   mergeChainedOptions,
+  isNil,
 } from '@rsbuild/shared';
 import type {
   HtmlConfig,
@@ -38,6 +39,7 @@ export function getInject(entryName: string, config: NormalizedConfig) {
     options: config.html.inject,
     utils: { entryName },
     useObjectParam: true,
+    isFalsy: isNil,
   });
 }
 
diff --git a/packages/shared/src/mergeChainedOptions.ts b/packages/shared/src/mergeChainedOptions.ts
index d77e6b2d59..a3182de616 100644
--- a/packages/shared/src/mergeChainedOptions.ts
+++ b/packages/shared/src/mergeChainedOptions.ts
@@ -7,43 +7,54 @@ import { isFunction, isPlainObject } from './utils';
 
 export type Falsy = false | null | undefined;
 
-export function mergeChainedOptions<T, U>(params: {
+export function mergeChainedOptions<T, U = unknown, F = Falsy>(params: {
   defaults: T;
-  options?: ChainedConfig<T> | Falsy;
+  options?: ChainedConfig<T> | F;
   mergeFn?: typeof Object.assign;
+  isFalsy?: (v: T | ChainedConfig<T> | F) => v is F;
 }): T;
-export function mergeChainedOptions<T, U>(params: {
+export function mergeChainedOptions<T, U, F = Falsy>(params: {
   defaults: T;
-  options: ChainedConfigWithUtils<T, U> | Falsy;
+  options: ChainedConfigWithUtils<T, U> | F;
   utils: U;
   mergeFn?: typeof Object.assign;
+  isFalsy?: (v: T | ChainedConfigWithUtils<T, U> | F) => v is F;
 }): T;
-export function mergeChainedOptions<T, U>(params: {
+export function mergeChainedOptions<T, U, F = Falsy>(params: {
   defaults: T;
-  options: ChainedConfigCombineUtils<T, U> | Falsy;
+  options: ChainedConfigCombineUtils<T, U> | F;
   utils: U;
   mergeFn?: typeof Object.assign;
   useObjectParam?: true;
+  isFalsy?: (v: T | ChainedConfigCombineUtils<T, U> | F) => v is F;
 }): T;
-export function mergeChainedOptions<T extends Record<string, unknown>>({
+export function mergeChainedOptions<T, U = unknown, F = Falsy>({
   defaults,
   options,
   utils,
   mergeFn = Object.assign,
   useObjectParam,
+  isFalsy = (v: unknown | F | undefined): v is F => !v,
 }: {
   defaults: T;
-  options?: unknown;
-  utils?: unknown;
+  options?: unknown | F;
+  utils?: U;
   mergeFn?: typeof Object.assign;
   useObjectParam?: true;
+  /**
+   * To determine if current value is falsy. `false | undefined | null` as falsy by default.
+   */
+  isFalsy?: (v: unknown | F | undefined) => v is F;
 }) {
-  if (!options) {
+  if (isFalsy(options)) {
     return defaults;
   }
 
   if (isPlainObject(options)) {
-    return mergeFn(defaults, options);
+    return mergeFn(
+      defaults /* we assume that when options is an object, defaults should be an object */ as any,
+      options,
+    );
   }
 
   if (isFunction(options)) {
@@ -54,7 +65,7 @@ export function mergeChainedOptions<T extends Record<string, unknown>>({
         })
       : options(defaults, utils);
 
-    return ret || defaults;
+    return isFalsy(ret) ? defaults : ret;
   } else if (Array.isArray(options)) {
     return options.reduce(
       (defaults, options) =>
@@ -64,6 +75,7 @@ export function mergeChainedOptions<T extends Record<string, unknown>>({
           utils,
           mergeFn,
           useObjectParam,
+          isFalsy,
         }),
       defaults,
     );
diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts
index a9f5cac61e..90fc3945f1 100644
--- a/packages/shared/src/utils.ts
+++ b/packages/shared/src/utils.ts
@@ -43,6 +43,9 @@ export const isPlainObject = (obj: unknown): obj is Record<string, any> =>
 export const isRegExp = (obj: any): obj is RegExp =>
   Object.prototype.toString.call(obj) === '[object RegExp]';
 
+export const isNil = (o: unknown): o is undefined | null =>
+  o === undefined || o === null;
+
 export const createVirtualModule = (content: string) =>
   `data:text/javascript,${content}`;