From 62fe1169d9c749c18b5706d461c79b57511483f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Wed, 29 Apr 2026 14:05:51 +0200 Subject: [PATCH] feat: use client no-ssr --- docs/src/components/ReactViteScene.jsx | 12 +- .../en/(pages)/guide/client-components.mdx | 60 ++++++++ .../ja/(pages)/guide/client-components.mdx | 60 ++++++++ packages/react-server/lib/build/server.mjs | 13 +- .../lib/plugins/file-router/plugin.mjs | 5 +- .../lib/plugins/use-client-inline.mjs | 5 +- .../react-server/lib/plugins/use-client.mjs | 137 +++++++++++++++++- .../lib/plugins/use-directive-inline.mjs | 16 +- .../react-server/lib/plugins/use-server.mjs | 3 +- .../react-server/lib/utils/directives.mjs | 32 ++++ packages/react-server/lib/utils/module.mjs | 10 +- 11 files changed, 331 insertions(+), 22 deletions(-) create mode 100644 packages/react-server/lib/utils/directives.mjs diff --git a/docs/src/components/ReactViteScene.jsx b/docs/src/components/ReactViteScene.jsx index d5b440d2..7dd73962 100644 --- a/docs/src/components/ReactViteScene.jsx +++ b/docs/src/components/ReactViteScene.jsx @@ -1,11 +1,19 @@ -"use client"; +"use client; no-ssr"; import { useRef, useEffect } from "react"; + import * as THREE from "three"; import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js"; +import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js"; import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js"; import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js"; -import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js"; + +// `"use client; no-ssr"` keeps three.js out of the SSR/edge bundle: the +// SSR build replaces this module with a null-stub (no imports) and the +// client build wraps the export in , so the heavy WebGL +// shaders and post-processing helpers only ever ship — and only ever +// execute — in the browser. Static imports here are deliberate; the +// directive handles the "don't bundle on the server" half. function isDarkMode() { return document.documentElement.classList.contains("dark"); diff --git a/docs/src/pages/en/(pages)/guide/client-components.mdx b/docs/src/pages/en/(pages)/guide/client-components.mdx index 8da018e5..c9db0216 100644 --- a/docs/src/pages/en/(pages)/guide/client-components.mdx +++ b/docs/src/pages/en/(pages)/guide/client-components.mdx @@ -327,3 +327,63 @@ export default function MyServerComponent() { ); } ``` + +`ClientOnly` is a *rendering* guard — it controls when its children render, but it does not control what gets bundled. The wrapped component and its imports still ship in the SSR bundle and run during server rendering. For components that pull in heavy browser-only dependencies (WebGL, Canvas, IntersectionObserver-based libraries, anything that touches `window` at module scope), use the [`"use client; no-ssr"`](#no-ssr-client-components) directive below — it removes the implementation from the SSR bundle entirely. + + +## No-SSR client components + + +A `"use client"` component still renders on the server during SSR — only its interactivity is deferred to the client. Its module graph is part of the SSR bundle, so any imports it pulls in are bundled and evaluated on the server. For components whose dependencies only make sense in the browser — Three.js, charting libraries, code editors, anything that touches `window` or `document` at module scope — that means shipping (and parsing) hundreds of KiB of code into your edge worker just to render an empty wrapper. + +The `"use client; no-ssr"` directive avoids this. It tells the runtime to compile the module differently for each environment: + +- **Server build**: the module is replaced with a null-rendering stub. None of the original code or its imports appear in the SSR bundle. +- **Client build**: the module is wrapped in `ClientOnly` automatically. The real component imports its dependencies as normal, but only renders after hydration — matching the server's null output and avoiding hydration mismatch. + +```jsx filename="src/components/Scene.jsx" +"use client; no-ssr"; + +import { useEffect, useRef } from "react"; +import * as THREE from "three"; + +export default function Scene() { + const ref = useRef(null); + + useEffect(() => { + const renderer = new THREE.WebGLRenderer(); + ref.current.appendChild(renderer.domElement); + // ... + return () => renderer.dispose(); + }, []); + + return
; +} +``` + +Used from a server component, the import looks identical to any other client component: + +```jsx +import Scene from "./components/Scene.jsx"; + +export default function Page() { + return ( +
+

Welcome

+ +
+ ); +} +``` + +The server renders `

Welcome

` — no `` markup, no Three.js evaluation, no Three.js code in the SSR bundle. After the page hydrates, the browser loads the `Scene` chunk, runs the effect, and mounts the canvas in place. + +Reach for `"use client; no-ssr"` when: + +- The component depends on browser-only globals (`window`, `document`, `navigator`, WebGL/Canvas contexts) at module scope. +- The dependency graph is large and only meaningful in the browser (3D scenes, rich-text editors, video players, charting libraries with DOM measurement). +- You want the module to be a separate client chunk that loads only on pages where the component appears. + +For interactive components that have no browser-only dependencies, plain `"use client"` is the right default — it gives you SSR markup for free, which is what users see before hydration completes. + +> **Note:** Like `ClientOnly`, a `"use client; no-ssr"` component renders nothing until after hydration. Reserve it for components where the alternative (no SSR markup) is acceptable, and consider rendering a placeholder around it for layout stability. diff --git a/docs/src/pages/ja/(pages)/guide/client-components.mdx b/docs/src/pages/ja/(pages)/guide/client-components.mdx index f4696203..77d8b99e 100644 --- a/docs/src/pages/ja/(pages)/guide/client-components.mdx +++ b/docs/src/pages/ja/(pages)/guide/client-components.mdx @@ -255,3 +255,63 @@ export default function MyServerComponent() { ); } ``` + +`ClientOnly`は*レンダリング*ガードです — 子コンポーネントがいつレンダリングされるかは制御しますが、何がバンドルされるかは制御しません。ラップされたコンポーネントとそのインポートは依然としてSSRバンドルに含まれ、サーバーレンダリング中に実行されます。ブラウザ専用の重い依存関係(WebGL、Canvas、IntersectionObserverベースのライブラリ、モジュールスコープで`window`に触れるものすべて)を読み込むコンポーネントには、以下の[`"use client; no-ssr"`](#no-ssr-client-components)ディレクティブを使用してください — 実装そのものをSSRバンドルから完全に取り除きます。 + + +## SSRなしのクライアントコンポーネント + + +`"use client"`コンポーネントは、SSR中に依然としてサーバーでレンダリングされます — クライアントに延期されるのはそのインタラクティブ性だけです。そのモジュールグラフはSSRバンドルの一部であり、取り込まれるすべてのインポートはサーバーでバンドルされ評価されます。依存関係がブラウザでしか意味を持たないコンポーネント — Three.js、チャートライブラリ、コードエディタ、モジュールスコープで`window`や`document`に触れるもの — の場合、これは空のラッパーをレンダリングするためだけに数百KiBのコードをエッジワーカーに配信し(そして解析する)ことを意味します。 + +`"use client; no-ssr"`ディレクティブはこれを回避します。ランタイムに対して、モジュールを環境ごとに異なる方法でコンパイルするよう指示します: + +- **サーバービルド**: モジュールはnullを返すスタブに置き換えられます。元のコードもそのインポートも、SSRバンドルには現れません。 +- **クライアントビルド**: モジュールは`ClientOnly`で自動的にラップされます。実コンポーネントは通常通り依存関係をインポートしますが、ハイドレーション後にのみレンダリングされます — サーバーのnull出力と一致し、ハイドレーション不一致を回避します。 + +```jsx filename="src/components/Scene.jsx" +"use client; no-ssr"; + +import { useEffect, useRef } from "react"; +import * as THREE from "three"; + +export default function Scene() { + const ref = useRef(null); + + useEffect(() => { + const renderer = new THREE.WebGLRenderer(); + ref.current.appendChild(renderer.domElement); + // ... + return () => renderer.dispose(); + }, []); + + return
; +} +``` + +サーバーコンポーネントから使用すると、インポートは他のクライアントコンポーネントとまったく同じに見えます: + +```jsx +import Scene from "./components/Scene.jsx"; + +export default function Page() { + return ( +
+

Welcome

+ +
+ ); +} +``` + +サーバーは`

Welcome

`をレンダリングします — ``のマークアップも、Three.jsの評価も、SSRバンドル内のThree.jsのコードもありません。ページがハイドレーションされた後、ブラウザは`Scene`チャンクを読み込み、エフェクトを実行し、その場所にcanvasをマウントします。 + +次の場合に`"use client; no-ssr"`を使用してください: + +- コンポーネントがモジュールスコープでブラウザ専用のグローバル(`window`、`document`、`navigator`、WebGL/Canvasコンテキスト)に依存する。 +- 依存関係グラフが大きく、ブラウザでのみ意味を持つ(3Dシーン、リッチテキストエディタ、動画プレイヤー、DOM計測を伴うチャートライブラリ)。 +- そのモジュールを、コンポーネントが現れるページでのみ読み込まれる別のクライアントチャンクにしたい。 + +ブラウザ専用の依存関係を持たないインタラクティブなコンポーネントには、通常の`"use client"`が適切なデフォルトです — ハイドレーションが完了するまでにユーザーが目にするSSRマークアップを無料で得られます。 + +> **注意:** `ClientOnly`と同様に、`"use client; no-ssr"`コンポーネントはハイドレーション後までは何もレンダリングしません。代替(SSRマークアップなし)が許容できるコンポーネントに留めて、レイアウトの安定性のために周囲にプレースホルダをレンダリングすることを検討してください。 diff --git a/packages/react-server/lib/build/server.mjs b/packages/react-server/lib/build/server.mjs index 3b9b0180..49953dcf 100644 --- a/packages/react-server/lib/build/server.mjs +++ b/packages/react-server/lib/build/server.mjs @@ -36,6 +36,7 @@ import { serverReferenceMapVirtual } from "../plugins/server-reference-map.mjs"; import { clientReferenceMapVirtual } from "../plugins/client-reference-map.mjs"; import * as sys from "../sys.mjs"; import { parse as parseAst } from "../utils/ast.mjs"; +import { parseClientDirective } from "../utils/directives.mjs"; import { makeResolveAlias } from "../utils/config.mjs"; import merge from "../utils/merge.mjs"; import { @@ -337,8 +338,13 @@ export default async function serverBuild(root, options, clientManifestBus) { let isGlobalErrorClientComponent = false; try { const code = await readFile(globalError, "utf8"); + // Substring check is intentionally loose: it matches both the bare + // `"use client"` directive and any modifier form (`"use client; + // no-ssr"`, `"use client; deferred"`, …) by leaving the closing + // quote off. Refining further would require a full parse just to + // set a single boolean — not worth it for the global error file. isGlobalErrorClientComponent = - code.includes(`"use client"`) || code.includes(`'use client'`); + code.includes('"use client') || code.includes("'use client"); } catch { // ignore } @@ -373,13 +379,14 @@ export default async function serverBuild(root, options, clientManifestBus) { if (rootModulePath && !rootModulePath.startsWith("virtual:")) { try { const code = await readFile(rootModulePath, "utf8"); - if (code.includes(`"use client"`) || code.includes(`'use client'`)) { + if (code.includes('"use client') || code.includes("'use client")) { const ast = await parseAst(code, rootModulePath); if (ast) { const directives = ast.body .filter((node) => node.type === "ExpressionStatement") .map(({ directive }) => directive); - isClientRootBuild = directives.includes("use client"); + isClientRootBuild = + parseClientDirective(directives)?.isClient ?? false; } } } catch { diff --git a/packages/react-server/lib/plugins/file-router/plugin.mjs b/packages/react-server/lib/plugins/file-router/plugin.mjs index c365d0bf..f62407a3 100644 --- a/packages/react-server/lib/plugins/file-router/plugin.mjs +++ b/packages/react-server/lib/plugins/file-router/plugin.mjs @@ -6,6 +6,7 @@ import { basename, dirname, extname, join, relative } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { parse as parseAST } from "../../utils/ast.mjs"; +import { parseClientDirective } from "../../utils/directives.mjs"; import { loadConfig } from "@lazarv/react-server/config"; import { forChild, forRoot } from "@lazarv/react-server/config/context.mjs"; import * as sys from "@lazarv/react-server/lib/sys.mjs"; @@ -168,7 +169,7 @@ async function isClientPageSource(src) { const directives = ast.body .filter((node) => node.type === "ExpressionStatement") .map(({ directive }) => directive); - if (!directives.includes("use client")) { + if (!parseClientDirective(directives)?.isClient) { clientPageCache.set(src, false); return false; } @@ -276,7 +277,7 @@ async function isClientSource(src) { const directives = ast.body .filter((node) => node.type === "ExpressionStatement") .map(({ directive }) => directive); - return directives.includes("use client"); + return parseClientDirective(directives)?.isClient ?? false; } /** diff --git a/packages/react-server/lib/plugins/use-client-inline.mjs b/packages/react-server/lib/plugins/use-client-inline.mjs index 2f116a44..a740dade 100644 --- a/packages/react-server/lib/plugins/use-client-inline.mjs +++ b/packages/react-server/lib/plugins/use-client-inline.mjs @@ -1,3 +1,5 @@ +import { parseClientDirective } from "../utils/directives.mjs"; + // Inject captured scope variables as destructured props for client components. function injectCapturedParams(fnSource, targetFn, capturedVars) { const capturedList = capturedVars.join(", "); @@ -45,7 +47,8 @@ function injectCapturedParams(fnSource, targetFn, capturedVars) { export const useClientInlineConfig = { directive: "use client", queryKey: "use-client-inline", - skipIfModuleDirective: ["use client"], + skipIfModuleDirective: (moduleDirectives) => + parseClientDirective(moduleDirectives)?.isClient ?? false, injectCapturedParams, buildCallSiteReplacement(importName, inlineId, capturedVars) { if (capturedVars.length === 0) return null; // use default (inline import) diff --git a/packages/react-server/lib/plugins/use-client.mjs b/packages/react-server/lib/plugins/use-client.mjs index 7df40023..419c952f 100644 --- a/packages/react-server/lib/plugins/use-client.mjs +++ b/packages/react-server/lib/plugins/use-client.mjs @@ -1,8 +1,9 @@ -import { realpath } from "node:fs/promises"; +import { readFile, realpath } from "node:fs/promises"; import { extname, relative } from "node:path"; import * as sys from "../sys.mjs"; import { codegen, parse, walk } from "../utils/ast.mjs"; +import { parseClientDirective } from "../utils/directives.mjs"; const cwd = sys.cwd(); const isClientComponent = new Map(); @@ -53,8 +54,21 @@ export default function useClient(type, manifest, enforce, clientComponentBus) { if (source === "client-components-bus") { return source; } + if (source.startsWith("virtual:no-ssr-original:")) { + return source; + } }, - load(id) { + async load(id) { + if (id.startsWith("virtual:no-ssr-original:")) { + // Loaded only by the client build, when wrapping a + // `"use client; no-ssr"` module. Returns the original source + // verbatim — the directive is preserved so any directive-aware + // plugin downstream still recognises the module as a client + // component. The transform handler skips this id explicitly to + // avoid re-entering the wrapper transform for the same source. + const realPath = id.slice("virtual:no-ssr-original:".length); + return await readFile(realPath, "utf-8"); + } if (id === "client-components-bus") { return new Promise((resolve) => { try { @@ -103,13 +117,14 @@ export default function useClient(type, manifest, enforce, clientComponentBus) { .filter((node) => node.type === "ExpressionStatement") .map(({ directive }) => directive); - const type = directives.includes("use client"); + const isClientDirective = + parseClientDirective(directives)?.isClient ?? false; + const type = isClientDirective; const prevType = isClientComponent.get(file); isClientComponent.set(file, type); if ( - (this.environment.name === "rsc" && - !directives.includes("use client")) || + (this.environment.name === "rsc" && !isClientDirective) || prevType !== type ) { this.environment.hot.send({ @@ -141,8 +156,29 @@ export default function useClient(type, manifest, enforce, clientComponentBus) { const viteEnv = this.environment.name; const mode = this.environment.mode; + // The wrapper emitted by this plugin imports the original source + // through `virtual:no-ssr-original:`. When the loader hands + // that source back to the transform pipeline we must NOT re-enter + // the wrapper logic, otherwise the bundler graph would loop on + // itself. Skip the virtual id and let downstream plugins handle + // the original module like any other `"use client"` file. + if (id.startsWith("virtual:no-ssr-original:")) return null; + + // Cheap source-string probe: only the client build needs to enter + // this transform when a `"use client; no-ssr"` module is involved + // (so it can emit a wrapper that imports the original through the + // `virtual:no-ssr-original:` channel). The check is loose on + // purpose — directive whitespace is permissive ("use client; + // no-ssr", "use client; no-ssr", …) — and false positives are + // caught by the parsed-directive guard below. + const maybeNoSSR = + type === "client" && + mode === "build" && + enforce === "pre" && + code.includes("no-ssr"); + if ( - type === "client" || + (type === "client" && !maybeNoSSR) || (mode !== "build" && (viteEnv === "client" || viteEnv === "ssr")) ) { return null; @@ -160,7 +196,15 @@ export default function useClient(type, manifest, enforce, clientComponentBus) { .filter((node) => node.type === "ExpressionStatement") .map(({ directive }) => directive); - if (!directives.includes("use client")) return null; + const parsedClient = parseClientDirective(directives); + const isClientDirective = parsedClient?.isClient ?? false; + const isNoSSR = parsedClient?.isNoSSR ?? false; + + if (!isClientDirective) return null; + // Safety net for the loose `code.includes("no-ssr")` fast-path: + // a non-no-ssr client module that slipped through must not run + // the registerClientReference path in the client build. + if (type === "client" && !isNoSSR) return null; if (directives.includes("use server")) throw new Error( "Cannot use both 'use client' and 'use server' in the same module." @@ -279,6 +323,85 @@ export default function useClient(type, manifest, enforce, clientComponentBus) { } } + // `"use client; no-ssr"` short-circuits the normal client/SSR + // flow. The SSR build emits a null stub (no imports of the + // implementation, so heavy deps stay out of the worker bundle) + // while the client build emits a wrapper that pulls the real + // module in through a virtual id and renders it inside + // , preventing hydration mismatch against the + // null-rendering SSR stub. + if (isNoSSR && mode === "build" && enforce === "pre") { + // Detect default + named exports for stub/wrapper generation. + // Mirrors the manifest-population walk above, kept local so the + // standard `"use client"` path is untouched. + const hasDefault = ast.body.some( + (node) => + node.type === "ExportDefaultDeclaration" || + (node.type === "ExportNamedDeclaration" && + node.specifiers?.find( + ({ exported }) => exported?.name === "default" + )) + ); + const namedExportNames = new Set(); + for (const node of ast.body) { + if (node.type === "ExportNamedDeclaration") { + const names = [ + ...(node.declaration?.id?.name + ? [node.declaration.id.name] + : []), + ...(node.declaration?.declarations?.map( + ({ id }) => id.name + ) || []), + ...node.specifiers.map(({ exported }) => exported.name), + ]; + names.forEach((n) => { + if (n !== "default") namedExportNames.add(n); + }); + } + } + + if (type === "ssr") { + const stubLines = []; + if (hasDefault) { + stubLines.push("export default function () { return null; }"); + } + for (const n of namedExportNames) { + stubLines.push(`export function ${n}() { return null; }`); + } + const stubCode = stubLines.join("\n") + "\n"; + buildCache?.set(processKey, { originalId: id, code: stubCode }); + isClientComponent.set(id, true); + return stubCode; + } + + if (type === "client") { + const realIdNoQuery = realId.split("?")[0]; + const wrapperLines = [ + '"use client";', + `import * as __rs_orig__ from "virtual:no-ssr-original:${realIdNoQuery}";`, + 'import { ClientOnly as __rs_ClientOnly__ } from "@lazarv/react-server/client";', + 'import { createElement as __rs_createElement__ } from "react";', + ]; + if (hasDefault) { + wrapperLines.push( + "export default function (props) { return __rs_createElement__(__rs_ClientOnly__, null, __rs_createElement__(__rs_orig__.default, props)); }" + ); + } + for (const n of namedExportNames) { + wrapperLines.push( + `export function ${n}(props) { return __rs_createElement__(__rs_ClientOnly__, null, __rs_createElement__(__rs_orig__.${n}, props)); }` + ); + } + const wrapperCode = wrapperLines.join("\n") + "\n"; + buildCache?.set(processKey, { + originalId: id, + code: wrapperCode, + }); + isClientComponent.set(id, true); + return wrapperCode; + } + } + if (type === "ssr") { return null; } diff --git a/packages/react-server/lib/plugins/use-directive-inline.mjs b/packages/react-server/lib/plugins/use-directive-inline.mjs index 8d4037d6..caa6b69c 100644 --- a/packages/react-server/lib/plugins/use-directive-inline.mjs +++ b/packages/react-server/lib/plugins/use-directive-inline.mjs @@ -486,12 +486,22 @@ export default function useDirectiveInline(configs) { if (outermost.length === 0) return null; // Filter out functions whose directive is configured to be skipped - // when the module itself has a certain directive + // when the module itself has a certain directive. The + // `skipIfModuleDirective` field accepts either an array of + // strings (strict equality match against `moduleDirectives`) or + // a predicate function — the function form is what permissive + // directive grammars (e.g. `"use client; no-ssr"`) need so they + // can normalise on the way in instead of enumerating every + // whitespace variant. const toProcess = outermost.filter(({ directive }) => { const cfg = configByDirective.get(directive); if (cfg.skipIfModuleDirective) { - for (const skip of cfg.skipIfModuleDirective) { - if (moduleDirectives.includes(skip)) return false; + if (typeof cfg.skipIfModuleDirective === "function") { + if (cfg.skipIfModuleDirective(moduleDirectives)) return false; + } else { + for (const skip of cfg.skipIfModuleDirective) { + if (moduleDirectives.includes(skip)) return false; + } } } return true; diff --git a/packages/react-server/lib/plugins/use-server.mjs b/packages/react-server/lib/plugins/use-server.mjs index cf40b3bd..371488e8 100644 --- a/packages/react-server/lib/plugins/use-server.mjs +++ b/packages/react-server/lib/plugins/use-server.mjs @@ -3,6 +3,7 @@ import { extname, relative } from "node:path"; import { encryptActionId } from "../../server/action-crypto.mjs"; import * as sys from "../sys.mjs"; import { codegen, parse } from "../utils/ast.mjs"; +import { parseClientDirective } from "../utils/directives.mjs"; const cwd = sys.cwd(); @@ -27,7 +28,7 @@ export default function useServer(type, manifest) { .map(({ directive }) => directive); if (!directives.includes("use server")) return null; - if (directives.includes("use client")) + if (parseClientDirective(directives)?.isClient) throw new Error( "Cannot use both 'use client' and 'use server' in the same module." ); diff --git a/packages/react-server/lib/utils/directives.mjs b/packages/react-server/lib/utils/directives.mjs new file mode 100644 index 00000000..24a0ddfd --- /dev/null +++ b/packages/react-server/lib/utils/directives.mjs @@ -0,0 +1,32 @@ +/** + * Parse the directive prologue (the `directive` field of top-of-file + * ExpressionStatement nodes) and detect a `"use client"` directive, + * including modifier flags such as `; no-ssr`. + * + * The directive grammar is intentionally permissive: segments are + * separated by `;` and individual whitespace is ignored, so all of + * + * "use client" + * "use client; no-ssr" + * "use client;no-ssr" + * "use client ; no-ssr" + * "use client; no-ssr" + * + * resolve to the same `{ isClient: true, isNoSSR: true|false }` shape. + * + * Returns `null` when no `"use client"` directive is found, so the + * common idiom is `parseClientDirective(directives)?.isClient`. + */ +export function parseClientDirective(directives) { + if (!directives) return null; + for (const directive of directives) { + if (typeof directive !== "string") continue; + const parts = directive.split(";").map((p) => p.trim()); + if (parts[0] !== "use client") continue; + return { + isClient: true, + isNoSSR: parts.slice(1).some((p) => p === "no-ssr"), + }; + } + return null; +} diff --git a/packages/react-server/lib/utils/module.mjs b/packages/react-server/lib/utils/module.mjs index d64919e6..85342664 100644 --- a/packages/react-server/lib/utils/module.mjs +++ b/packages/react-server/lib/utils/module.mjs @@ -299,9 +299,13 @@ export function hasClientComponents(filePath) { if (!/\.(js|jsx|ts|tsx|mjs|mts)$/.test(filePath)) return false; const content = readFileCached(filePath); + // Loose substring probe — leaves the closing quote off so that any + // modifier form (`"use client; no-ssr"`, …) still matches without + // having to enumerate variants. False positives are harmless: callers + // use this only as a fast bail-out before deeper inspection. return ( content && - (content.includes(`"use client"`) || content.includes(`'use client'`)) + (content.includes('"use client') || content.includes("'use client")) ); } @@ -383,8 +387,8 @@ export async function hasClientComponentsAsync(pkgPath) { const content = await readFileCachedAsync(fullPath); if ( content && - (content.includes(`"use client"`) || - content.includes(`'use client'`)) + (content.includes('"use client') || + content.includes("'use client")) ) { return true; }