Просмотр исходного кода

precompile styles components with vite

Yuki Takei 1 месяц назад
Родитель
Сommit
484c89ec78
31 измененных файлов с 249 добавлено и 83 удалено
  1. 49 34
      .kiro/specs/migrate-to-turbopack/design.md
  2. 10 5
      .kiro/specs/migrate-to-turbopack/requirements.md
  3. 29 0
      .kiro/specs/migrate-to-turbopack/research.md
  4. 15 10
      .kiro/specs/migrate-to-turbopack/tasks.md
  5. 4 2
      apps/app/.claude/skills/app-commands/SKILL.md
  6. 1 0
      apps/app/.gitignore
  7. 4 2
      apps/app/package.json
  8. 2 1
      apps/app/src/client/components/Admin/AuditLog/DateRangePicker.tsx
  9. 6 0
      apps/app/src/client/components/Admin/AuditLog/DateRangePicker.vendor-styles.ts
  10. 3 2
      apps/app/src/client/components/Common/ImageCropModal.tsx
  11. 6 0
      apps/app/src/client/components/Common/ImageCropModal.vendor-styles.ts
  12. 6 0
      apps/app/src/client/components/GrowiEditor.vendor-styles.ts
  13. 4 4
      apps/app/src/client/components/PageComment/CommentEditor.tsx
  14. 2 1
      apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.tsx
  15. 6 0
      apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.vendor-styles.ts
  16. 1 1
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  17. 1 1
      apps/app/src/client/components/PageHistory/RevisionDiff.tsx
  18. 6 0
      apps/app/src/client/components/PageHistory/RevisionDiff.vendor-styles.ts
  19. 1 1
      apps/app/src/client/components/Presentation/Presentation.tsx
  20. 6 0
      apps/app/src/client/components/Presentation/Presentation.vendor-styles.ts
  21. 1 1
      apps/app/src/client/components/Presentation/Slides.tsx
  22. 2 2
      apps/app/src/client/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx
  23. 6 0
      apps/app/src/client/components/ReactMarkdownComponents/DrawioViewerWithEditButton.vendor-styles.ts
  24. 0 2
      apps/app/src/client/components/Sidebar/Sidebar.tsx
  25. 9 0
      apps/app/src/client/services/renderer/Renderer.vendor-styles.ts
  26. 1 2
      apps/app/src/client/services/renderer/renderer.tsx
  27. 0 2
      apps/app/src/components/PageView/RevisionRenderer.tsx
  28. 27 8
      apps/app/turbo.json
  29. 0 0
      apps/app/vite.vendor-styles-commons.ts
  30. 32 0
      apps/app/vite.vendor-styles-components.ts
  31. 9 2
      biome.json

+ 49 - 34
.kiro/specs/migrate-to-turbopack/design.md

@@ -11,7 +11,7 @@
 ### Goals
 - Enable Turbopack as the default bundler for `next dev` with the custom Express server
 - Migrate all 6 webpack customizations to Turbopack-compatible equivalents
-- Convert all global CSS imports to vendor CSS Module wrappers for Turbopack compliance
+- Precompile all vendor CSS into JS modules via Vite for Turbopack compliance
 - Convert all `:global` block form syntax in CSS Modules to function form for Turbopack compatibility
 - Maintain webpack fallback via environment variable during the transition period
 - Enable Turbopack for production `next build` after dev stability is confirmed
@@ -86,9 +86,9 @@ graph TB
 - Selected pattern: Feature-flag phased migration with dual configuration
 - Domain boundaries: Build configuration layer only — no changes to application code, server logic, or page components
 - Existing patterns preserved: Express custom server, Pages Router, SuperJSON SSR, server-client boundary
-- New components: Empty module file, Turbopack config block, env-based bundler selection, vendor CSS Module wrappers
+- New components: Empty module file, Turbopack config block, env-based bundler selection, Vite vendor CSS precompilation
 - Steering compliance: Maintains server-client boundary enforcement; aligns with existing build optimization strategy
-- CSS compatibility: All `:global` block form syntax converted to function form; all global CSS imports wrapped in CSS Modules
+- CSS compatibility: All `:global` block form syntax converted to function form; all vendor CSS precompiled via Vite into JS modules
 
 ### Technology Stack
 
@@ -157,10 +157,11 @@ sequenceDiagram
 | 6.3 | Production tests pass | BuildConfig | Test suite | - |
 | 7.1 | Alternative module analysis | Deferred | - | - |
 | 7.2 | DUMP_INITIAL_MODULES report | Deferred | - | - |
-| 8.1 | Vendor CSS Module wrappers for imports | VendorCSSWrappers | .module.scss files | - |
-| 8.2 | Convert all direct global CSS imports | VendorCSSWrappers | Component imports | - |
-| 8.3 | Naming convention vendor-*.module.scss | VendorCSSWrappers | File naming | - |
-| 8.4 | Stylelint override for vendor wrappers | VendorCSSWrappers | .stylelintrc.json | - |
+| 8.1 | Vendor CSS precompiled via Vite ?inline | VendorCSSPrecompilation | .vendor-styles.ts files | - |
+| 8.2 | All direct CSS imports migrated | VendorCSSPrecompilation | Component imports | - |
+| 8.3 | Naming convention *.vendor-styles.ts | VendorCSSPrecompilation | File naming | - |
+| 8.4 | Turborepo pre:styles-components tasks | VendorCSSPrecompilation | turbo.json | - |
+| 8.5 | Prebuilt files git-ignored | VendorCSSPrecompilation | .gitignore | - |
 | 9.1 | Function form `:global(...)` syntax | GlobalSyntaxConversion | 128 files | - |
 | 9.2 | Identical CSS output after conversion | GlobalSyntaxConversion | Compilation | - |
 | 9.3 | Nested blocks converted correctly | GlobalSyntaxConversion | Nested selectors | - |
@@ -179,7 +180,7 @@ sequenceDiagram
 | ResolveAliasConfig | Config | Alias server-only packages and fs in browser | 2.1, 2.2, 2.3 | empty-module.ts (P0) | - |
 | EmptyModule | Util | Provide empty export file for resolveAlias | 2.1, 2.2 | None | - |
 | I18nConfig | Config | Remove HMR plugins when Turbopack is active | 5.1, 5.2, 5.3 | next-i18next (P1) | - |
-| VendorCSSWrappers | CSS | Wrap third-party global CSS imports in CSS Modules | 8.1, 8.2, 8.3, 8.4 | Component imports (P0) | - |
+| VendorCSSPrecompilation | CSS/Build | Precompile third-party CSS into JS via Vite | 8.1, 8.2, 8.3, 8.4, 8.5 | Vite, Turborepo (P0) | - |
 | GlobalSyntaxConversion | CSS | Convert `:global` block form to function form | 9.1, 9.2, 9.3, 9.4, 9.5 | 128 .module.scss files (P0) | - |
 | BuildScripts | Config | Update package.json scripts for Turbopack | 6.1, 6.2, 10.1 | package.json (P0) | - |
 | WebpackFallback | Config | Maintain webpack() hook for fallback | 10.2, 10.3 | next.config.ts (P0) | - |
@@ -347,36 +348,50 @@ interface ResolveAliasConfig {
 
 ### CSS Compatibility Layer
 
-#### VendorCSSWrappers — Global CSS Import Migration
+#### VendorCSSPrecompilation — Global CSS Import Migration via Vite
 
 | Field | Detail |
 |-------|--------|
-| Intent | Wrap third-party global CSS imports in CSS Module files to comply with Turbopack's strict global CSS import rule |
-| Requirements | 8.1, 8.2, 8.3, 8.4 |
+| Intent | Precompile third-party CSS into JS modules via Vite to comply with Turbopack's strict global CSS import rule |
+| Requirements | 8.1, 8.2, 8.3, 8.4, 8.5 |
 
 **Responsibilities & Constraints**
-- Create `vendor-{library}.module.scss` wrapper files for each third-party CSS import
-- Use `:global { @import 'package/style.css'; }` pattern to preserve global scope
-- Replace direct `import 'package/style.css'` statements in components with `import './vendor-{library}.module.scss'`
-- Add stylelint override in `.stylelintrc.json` for `vendor-*.module.scss` to suppress `no-invalid-position-at-import-rule`
-
-**Affected Imports (13 total in 12 files)**
-- `@growi/remark-lsx`, `@growi/remark-attachment-refs` (renderer service)
-- `@growi/editor` (PageEditor)
-- `handsontable` (HandsontableModal)
-- `katex` (PageView)
-- `react-datepicker` (AuditLog)
-- `diff2html` (PageHistory)
-- `@growi/editor` (PageComment)
-- `drawio` (ReactMarkdownComponents)
-- `react-image-crop` (Common)
-- `simplebar-react` (Sidebar)
-- `reveal.js`, `highlight.js` (Presentation)
+- Create `{ComponentName}.vendor-styles.ts` entry point files using Vite's `?inline` CSS import suffix
+- Each entry point imports CSS as inline strings and injects them into `document.head` via `<style>` tag at runtime
+- Vite precompiles these into `{ComponentName}.vendor-styles.prebuilt.js` files (git-ignored, regenerated by Turborepo)
+- Components import the prebuilt `.js` file instead of raw CSS
+- Two-track system: commons track (`vendor.scss` for globally shared CSS) and components track (`*.vendor-styles.ts` for component-specific CSS)
+
+**Vite Build Configuration** (`vite.vendor-styles-components.ts`)
+- Collects all `src/**/*.vendor-styles.ts` as entry points via `fs.globSync`
+- Outputs to `src/` preserving directory structure, with `.vendor-styles.prebuilt.js` suffix
+- Uses `rollupOptions.input` (not `lib.entry`) for multi-entry compilation
+
+**Turborepo Integration**
+- `pre:styles-components` / `dev:pre:styles-components` tasks added as dependencies of `build` and `dev`
+- Inputs: `vite.vendor-styles-components.ts`, `src/**/*.vendor-styles.ts`, `package.json`
+- Outputs: `src/**/*.vendor-styles.prebuilt.js`
+
+**Vendor-Styles Entry Points (8 files covering 13 CSS imports)**
+
+| Entry Point | CSS Sources | Consuming Components |
+|---|---|---|
+| `Renderer.vendor-styles.ts` | `@growi/remark-lsx`, `@growi/remark-attachment-refs`, `katex` | renderer.tsx |
+| `GrowiEditor.vendor-styles.ts` | `@growi/editor` | PageEditor, CommentEditor |
+| `HandsontableModal.vendor-styles.ts` | `handsontable` (non-full variant) | HandsontableModal |
+| `DateRangePicker.vendor-styles.ts` | `react-datepicker` | DateRangePicker |
+| `RevisionDiff.vendor-styles.ts` | `diff2html` | RevisionDiff |
+| `DrawioViewerWithEditButton.vendor-styles.ts` | `@growi/remark-drawio` | DrawioViewerWithEditButton |
+| `ImageCropModal.vendor-styles.ts` | `react-image-crop` | ImageCropModal |
+| `Presentation.vendor-styles.ts` | `@growi/presentation` | Presentation, Slides |
+
+**Note**: `simplebar-react` CSS is handled by the commons track (`src/styles/vendor.scss`) — its direct import was simply removed from `Sidebar.tsx`.
 
 **Implementation Notes**
-- Naming convention: `vendor-{short-library-name}.module.scss`
-- Each wrapper imports exactly one third-party CSS file
-- Multiple CSS imports from the same component directory are combined into one wrapper when possible
+- Entry points use `// @ts-nocheck` since `?inline` is a Vite-specific import suffix not understood by TypeScript
+- Multiple CSS imports for the same component are combined in a single entry point (e.g., `Renderer.vendor-styles.ts` imports 3 CSS files)
+- Shared entry points (e.g., `GrowiEditor.vendor-styles.ts`) are referenced from multiple components via relative path
+- SSR note: CSS is injected at runtime via `<style>` tags, so it is not available during SSR. Most consuming components already use `next/dynamic` with `ssr: false`, avoiding FOUC
 
 #### GlobalSyntaxConversion — `:global` Block-to-Function Form
 
@@ -430,7 +445,7 @@ Build-time errors from Turbopack migration are the primary concern. All errors s
 
 **i18n Errors**: `Cannot find module 'i18next-hmr'` or HMR plugin crash — indicates HMR plugin loaded in Turbopack mode. Fix: guard HMR plugin loading with env var check.
 
-**Global CSS Import Errors**: `Global CSS cannot be imported from files other than your Custom <App>` — indicates a direct global CSS import from a non-`_app` file. Fix: create a `vendor-*.module.scss` wrapper and change the import.
+**Global CSS Import Errors**: `Global CSS cannot be imported from files other than your Custom <App>` — indicates a direct global CSS import from a non-`_app` file. Fix: create a `{Component}.vendor-styles.ts` entry point, run `pre:styles-components`, and import the prebuilt `.js` file instead.
 
 **CSS Modules Syntax Errors**: `Ambiguous CSS module class not supported` — indicates `:global` block form usage. Fix: convert the block form to function form `:global(...)`.
 
@@ -464,7 +479,7 @@ flowchart LR
     A[Add turbopack config] --> B[Add empty-module.ts]
     B --> C[Update crowi/index.ts]
     C --> D[Guard i18n HMR plugins]
-    D --> E[Vendor CSS wrappers]
+    D --> E[Vendor CSS precompilation]
     E --> F[Convert :global syntax]
     F --> G[Smoke test Turbopack dev]
     G --> H[Smoke test webpack fallback]
@@ -475,7 +490,7 @@ flowchart LR
 2. Create `src/lib/empty-module.ts`
 3. Update `src/server/crowi/index.ts`: replace `webpack: true` with `USE_WEBPACK` env var check
 4. Guard `HMRPlugin` in `next-i18next.config.js` with env var check
-5. Create vendor CSS Module wrappers for all third-party global CSS imports
+5. Precompile vendor CSS via Vite `?inline` into `.vendor-styles.prebuilt.js` files
 6. Convert all `:global` block form syntax to function form across 128 `.module.scss` files
 7. Run smoke tests with both Turbopack and webpack modes
 8. Merge — all developers now use Turbopack by default for dev

+ 10 - 5
.kiro/specs/migrate-to-turbopack/requirements.md

@@ -106,18 +106,23 @@ GROWI uses a custom Express server with `next({ dev, webpack: true })` to initia
 
 ### Requirement 8: Global CSS Import Restriction Compliance
 
-**Objective:** As a developer, I want third-party CSS files to be properly wrapped for Turbopack, so that no "Global CSS cannot be imported from files other than your Custom `<App>`" errors occur.
+**Objective:** As a developer, I want third-party CSS files to be properly handled for Turbopack, so that no "Global CSS cannot be imported from files other than your Custom `<App>`" errors occur.
 
 #### Background
 
 Turbopack strictly enforces the Pages Router rule that global CSS can only be imported from `_app.page.tsx`. Under webpack, this rule was not enforced — components could freely `import 'package/style.css'`. Turbopack rejects these imports at compile time.
 
+The solution uses a **two-track vendor CSS system**:
+- **Commons track** (`vendor.scss` → `src/styles/prebuilt/`): Globally shared vendor CSS (e.g., `simplebar-react`) compiled via `vite.vendor-styles-commons.ts`
+- **Components track** (`*.vendor-styles.ts` → `*.vendor-styles.prebuilt.js`): Component-specific vendor CSS precompiled via `vite.vendor-styles-components.ts` using Vite's `?inline` CSS import suffix
+
 #### Acceptance Criteria
 
-1. When a component imports third-party CSS (e.g., `handsontable`, `katex`, `diff2html`), the import shall use a vendor CSS Module wrapper (`.module.scss` with `:global { @import '...' }`) instead of a direct global CSS import.
-2. All existing direct global CSS imports from non-`_app` files shall be converted to vendor CSS Module wrappers.
-3. The vendor CSS Module wrapper files shall follow the naming convention `vendor-{library}.module.scss`.
-4. Stylelint shall not report errors on vendor CSS Module wrappers (override `no-invalid-position-at-import-rule` for `vendor-*.module.scss`).
+1. When a component requires third-party CSS (e.g., `handsontable`, `katex`, `diff2html`), the CSS shall be precompiled into a `.vendor-styles.prebuilt.js` file via Vite and imported as a regular JS module.
+2. All existing direct global CSS imports from non-`_app` files shall be migrated to either the commons track (if globally needed) or the components track (if component-specific).
+3. The vendor-styles entry point files shall follow the naming convention `{ComponentName}.vendor-styles.ts`, producing `{ComponentName}.vendor-styles.prebuilt.js` output.
+4. The prebuilt output files shall be git-ignored and regenerated by Turborepo `pre:styles-components` / `dev:pre:styles-components` tasks before build/dev.
+5. Vendor CSS precompilation shall use Vite's `?inline` suffix to inline CSS as a string, injecting it at runtime via `<style>` tag insertion into `document.head`.
 
 ### Requirement 9: CSS Modules `:global` Syntax Compatibility
 

+ 29 - 0
.kiro/specs/migrate-to-turbopack/research.md

@@ -105,6 +105,24 @@
   - `optimizePackageImports` (experimental) is also supported under Turbopack
 - **Implications**: Keep `transpilePackages` config as-is initially. Test removing entries incrementally after migration verified.
 
+### Vendor CSS Handling under Turbopack Pages Router
+- **Context**: Turbopack Pages Router strictly enforces that global CSS can only be imported from `_app.page.tsx`. Components importing vendor CSS (e.g., `import 'katex/dist/katex.min.css'`) fail to compile. Need a strategy that avoids centralizing all vendor CSS in `_app` (which would degrade FCP for pages that don't need those styles).
+- **Sources Consulted**:
+  - [Next.js CSS Modules Docs](https://nextjs.org/docs/app/getting-started/css#css-modules)
+  - [Vite CSS ?inline Suffix](https://vite.dev/guide/features.html#disabling-css-injection-into-the-page)
+  - Implementation experiments in this repository
+- **Approaches Evaluated**:
+  1. **Centralize in `_app.page.tsx`** — Move all vendor CSS imports to `_app`. Simple but degrades FCP for pages that don't need those styles.
+  2. **CSS Module wrappers** (`:global { @import '...' }`) — Create `vendor-*.module.scss` files wrapping vendor CSS in `:global {}`. Failed because Turbopack rejects `:global` block form entirely.
+  3. **Vite precompilation with `?inline`** — Create `*.vendor-styles.ts` entry points that import CSS via Vite's `?inline` suffix (inlines CSS as a string), then inject into `document.head` via `<style>` tags at runtime. Components import the prebuilt `.js` output instead of raw CSS.
+- **Findings**:
+  - Approach 2 failed: Turbopack does not support `:global` block form in CSS Modules — not just for selectors but also for `@import` wrappers
+  - Approach 3 works: Vite `?inline` converts CSS to a JS string export. The prebuilt JS file contains no CSS imports, so Turbopack sees it as a regular JS module
+  - `handsontable/dist/handsontable.full.min.css` contains IE CSS hacks (`*zoom:1`, `filter:alpha()`) that Turbopack's CSS parser (lightningcss) cannot parse. Switched to `handsontable/dist/handsontable.css` (non-full, non-minified variant)
+  - Vite's `?inline` approach runs at prebuild time (before Turbopack/Next.js), fitting naturally into the existing Turborepo task pipeline alongside `pre:styles-commons`
+  - SSR caveat: CSS injected via `<style>` tags is not available during SSR. Most consuming components already use `next/dynamic` with `ssr: false`, so FOUC is not a practical concern
+- **Implications**: Selected Approach 3. Two-track vendor CSS system: commons track (`vendor.scss` for globally shared CSS like `simplebar-react`) and components track (`*.vendor-styles.ts` for component-specific CSS precompiled by Vite).
+
 ## Architecture Pattern Evaluation
 
 | Option | Description | Strengths | Risks / Limitations | Notes |
@@ -148,6 +166,17 @@
 - **Trade-offs**: One extra file in the codebase.
 - **Follow-up**: Verify all 7 null-loader targets work correctly with the alias approach.
 
+### Decision: Vite Precompilation for Vendor CSS
+- **Context**: Turbopack Pages Router rejects global CSS imports outside `_app.page.tsx`. Need per-component vendor CSS without centralizing everything in `_app`.
+- **Alternatives Considered**:
+  1. Centralize all vendor CSS in `_app.page.tsx` — simple but degrades FCP
+  2. CSS Module wrappers with `:global { @import }` — Turbopack rejects `:global` block form
+  3. Vite precompilation with `?inline` suffix — CSS inlined into JS at prebuild time
+- **Selected Approach**: Option 3 — Vite precompilation
+- **Rationale**: Keeps CSS co-located with consuming components (no FCP penalty for unrelated pages). Fits naturally into the existing Turborepo prebuild pipeline (parallel to `pre:styles-commons`). No Turbopack restrictions apply since the output is pure JS.
+- **Trade-offs**: Additional prebuild step (fast in practice). Runtime CSS injection means styles are not available during SSR (acceptable since most consuming components use `ssr: false`).
+- **Naming Convention**: `{ComponentName}.vendor-styles.ts` → `{ComponentName}.vendor-styles.prebuilt.js`
+
 ## Risks & Mitigations
 - **Risk 1**: next-i18next may have runtime issues under Turbopack (not just HMR) — **Mitigation**: Test i18n routing and SSR early; maintain webpack fallback flag
 - **Risk 2**: superjson-ssr-loader may use unsupported loader-runner API features — **Mitigation**: The loader only performs regex-based string transforms on the `source` argument; no advanced APIs used. If issues arise, convert to a Babel plugin or code generation.

+ 15 - 10
.kiro/specs/migrate-to-turbopack/tasks.md

@@ -41,18 +41,23 @@
   - Translation file changes under Turbopack require a manual browser refresh (documented tradeoff)
   - _Requirements: 5.1, 5.2, 5.3_
 
-- [x] 4. Centralize vendor CSS imports in _app.page.tsx for Turbopack compatibility
-- [x] 4.1 Move all vendor CSS imports from component files to _app.page.tsx
-  - Turbopack Pages Router only allows global CSS imports from `_app`. The `:global { @import }` wrapper approach also failed because Turbopack rejects `:global` block form entirely.
-  - Moved 13 vendor CSS imports from 12 component files to `_app.page.tsx`
+- [x] 4. Precompile vendor CSS via Vite for Turbopack compatibility
+- [x] 4.1 Create Vite config and Turborepo tasks for vendor CSS precompilation
+  - Created `vite.vendor-styles-components.ts` — collects all `src/**/*.vendor-styles.ts` as entry points, precompiles via Vite `?inline` suffix
+  - Added `pre:styles-components` / `dev:pre:styles-components` tasks to `turbo.json` as dependencies of `build` and `dev`
+  - Added corresponding npm scripts to `package.json`
+  - Added `/src/**/*.vendor-styles.prebuilt.js` to `.gitignore`
+  - _Requirements: 8.3, 8.4, 8.5_
+
+- [x] 4.2 Create vendor-styles entry points and migrate CSS imports from components
+  - Created 8 `*.vendor-styles.ts` entry point files covering 13 vendor CSS imports from 12 component files
+  - Each entry point uses `?inline` CSS import and injects into `document.head` via `<style>` tag
+  - Replaced direct CSS imports in components with `.vendor-styles.prebuilt` JS imports
   - Switched `handsontable/dist/handsontable.full.min.css` to `handsontable/dist/handsontable.css` (non-full, non-minified) to avoid IE CSS hack parse errors in Turbopack
-  - Removed all `vendor-*.module.scss` wrapper files (approach abandoned)
+  - `simplebar-react` CSS handled by commons track (`vendor.scss`) — direct import simply removed from `Sidebar.tsx`
+  - `katex` CSS added to `Renderer.vendor-styles.ts` (used by `rehype-katex` in the renderer)
   - _Requirements: 8.1, 8.2, 8.3_
 
-- [x] 4.2 Clean up stale stylelint overrides
-  - Removed `vendor-*.module.scss` override from `.stylelintrc.json` (files no longer exist)
-  - _Requirements: 8.4_
-
 - [x] 5. Convert `:global` block form to function form in CSS Modules
 - [x] 5.1 (P) Convert all `:global` block form syntax to function form across all `.module.scss` files
   - Scan all `.module.scss` files for `:global {` block form usage (128 files, 255 occurrences)
@@ -117,7 +122,7 @@
 ## Implementation Notes (Discovered During Phase 1)
 
 - **resolveAlias paths**: Turbopack `resolveAlias` requires **relative paths** (e.g., `./src/lib/empty-module.ts`), not absolute paths from `path.resolve()`. Absolute paths cause "server relative imports are not implemented yet" errors.
-- **Vendor CSS centralized in _app.page.tsx**: The `vendor-*.module.scss` wrapper approach (`:global { @import }`) failed because Turbopack rejects `:global` block form entirely. All 13 vendor CSS imports were moved to `_app.page.tsx` — the only file where Turbopack Pages Router allows global CSS imports.
+- **Vendor CSS precompiled via Vite**: The `vendor-*.module.scss` wrapper approach (`:global { @import }`) failed because Turbopack rejects `:global` block form entirely. The `_app.page.tsx` centralization approach was also rejected due to FCP degradation. Final solution: Vite precompilation with `?inline` suffix — 8 `*.vendor-styles.ts` entry points covering 13 vendor CSS imports, precompiled into `.vendor-styles.prebuilt.js` files by Turborepo `pre:styles-components` task.
 - **`:global` block form**: Turbopack's CSS Modules implementation only supports the function form `:global(...)`. The block form `:global { }` (supported by webpack) causes "Ambiguous CSS module class not supported" errors. Conversion is mechanical — 128 files, 255 occurrences.
 - **Standalone `:local`**: Turbopack doesn't support standalone `:local` or `&:local` in CSS Modules. Inside `:global(...)` function form, properties are already locally scoped by default, so `&:local` wrappers can simply be removed.
 - **Sass `@extend` in CSS Modules**: `@extend .class` fails when the target is wrapped in `:global(.class)` — Sass doesn't match them as the same selector. Replace with shared selector groups (comma-separated selectors).

+ 4 - 2
apps/app/.claude/skills/app-commands/SKILL.md

@@ -101,10 +101,12 @@ Generated specs output to `tmp/openapi-spec-apiv3.json`.
 
 ```bash
 # Development mode
-pnpm run dev:pre:styles
+pnpm run dev:pre:styles-commons
+pnpm run dev:pre:styles-components
 
 # Production mode
-pnpm run pre:styles
+pnpm run pre:styles-commons
+pnpm run pre:styles-commons-components
 ```
 
 Pre-builds SCSS styles into CSS bundles using Vite.

+ 1 - 0
apps/app/.gitignore

@@ -13,6 +13,7 @@
 /public/static/styles
 /public/uploads
 /src/styles/prebuilt
+/src/**/*.vendor-styles.prebuilt.js
 /tmp/
 
 # cache

+ 4 - 2
apps/app/package.json

@@ -14,11 +14,13 @@
     "server": "cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
     "server:ci": "pnpm run server --ci",
     "preserver": "cross-env NODE_ENV=production pnpm run migrate",
-    "pre:styles": "vite build -c vite.styles-prebuilt.config.ts",
+    "pre:styles-commons": "vite build -c vite.vendor-styles-commons.ts",
+    "pre:styles-components": "vite build --config vite.vendor-styles-components.ts",
     "migrate": "node -r dotenv-flow/config node_modules/migrate-mongo/bin/migrate-mongo up -f config/migrate-mongo-config.js",
     "//// for development": "",
     "dev": "cross-env NODE_ENV=development nodemon --exec pnpm run ts-node --inspect src/server/app.ts",
-    "dev:pre:styles": "pnpm run pre:styles --mode dev",
+    "dev:pre:styles-commons": "pnpm run pre:styles-commons --mode dev",
+    "dev:pre:styles-components": "pnpm run pre:styles-components",
     "dev:migrate-mongo": "cross-env NODE_ENV=development pnpm run ts-node node_modules/migrate-mongo/bin/migrate-mongo",
     "dev:migrate": "pnpm run dev:migrate:status > tmp/cache/migration-status.out && pnpm run dev:migrate:up",
     "dev:migrate:status": "pnpm run dev:migrate-mongo status -f config/migrate-mongo-config.js",

+ 2 - 1
apps/app/src/client/components/Admin/AuditLog/DateRangePicker.tsx

@@ -3,7 +3,8 @@ import { forwardRef, useCallback } from 'react';
 import { addDays } from 'date-fns/addDays';
 import { format } from 'date-fns/format';
 import DatePicker from 'react-datepicker';
-import 'react-datepicker/dist/react-datepicker.css';
+
+import './DateRangePicker.vendor-styles.prebuilt';
 
 type CustomInputProps = {
   value?: string;

+ 6 - 0
apps/app/src/client/components/Admin/AuditLog/DateRangePicker.vendor-styles.ts

@@ -0,0 +1,6 @@
+// @ts-nocheck -- Processed by Vite only; ?inline is a Vite-specific import suffix
+import css from 'react-datepicker/dist/react-datepicker.css?inline';
+
+const s = document.createElement('style');
+s.textContent = css;
+document.head.appendChild(s);

+ 3 - 2
apps/app/src/client/components/Common/ImageCropModal.tsx

@@ -1,5 +1,5 @@
 import type { FC } from 'react';
-import React, { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
 import canvasToBlob from 'async-canvas-to-blob';
 import { useTranslation } from 'react-i18next';
 import ReactCrop from 'react-image-crop';
@@ -7,7 +7,8 @@ import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import { toastError } from '~/client/util/toastr';
 import loggerFactory from '~/utils/logger';
-import 'react-image-crop/dist/ReactCrop.css';
+
+import './ImageCropModal.vendor-styles.prebuilt';
 
 const logger = loggerFactory('growi:ImageCropModal');
 

+ 6 - 0
apps/app/src/client/components/Common/ImageCropModal.vendor-styles.ts

@@ -0,0 +1,6 @@
+// @ts-nocheck -- Processed by Vite only; ?inline is a Vite-specific import suffix
+import css from 'react-image-crop/dist/ReactCrop.css?inline';
+
+const s = document.createElement('style');
+s.textContent = css;
+document.head.appendChild(s);

+ 6 - 0
apps/app/src/client/components/GrowiEditor.vendor-styles.ts

@@ -0,0 +1,6 @@
+// @ts-nocheck -- Processed by Vite only; ?inline is a Vite-specific import suffix
+import css from '@growi/editor/dist/style.css?inline';
+
+const s = document.createElement('style');
+s.textContent = css;
+document.head.appendChild(s);

+ 4 - 4
apps/app/src/client/components/PageComment/CommentEditor.tsx

@@ -1,5 +1,5 @@
 import type { JSX, ReactNode } from 'react';
-import React, {
+import {
   useCallback,
   useEffect,
   useLayoutEffect,
@@ -35,11 +35,11 @@ import { NotAvailableIfReadOnlyUserNotAllowedToComment } from '../NotAvailableFo
 import { CommentPreview } from './CommentPreview';
 import { SwitchingButtonGroup } from './SwitchingButtonGroup';
 
-import '@growi/editor/dist/style.css';
-
 import styles from './CommentEditor.module.scss';
 
-const logger = loggerFactory('growi:components:CommentEditor');
+import '../GrowiEditor.vendor-styles.prebuilt';
+
+const _logger = loggerFactory('growi:components:CommentEditor');
 
 const SlackNotification = dynamic(
   () => import('../SlackNotification').then((mod) => mod.SlackNotification),

+ 2 - 1
apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.tsx

@@ -35,7 +35,8 @@ import ExpandOrContractButton from '../../ExpandOrContractButton';
 import { MarkdownTableDataImportForm } from '../MarkdownTableDataImportForm';
 
 import styles from './HandsontableModal.module.scss';
-import 'handsontable/dist/handsontable.full.min.css';
+
+import './HandsontableModal.vendor-styles.prebuilt';
 
 const DEFAULT_HOT_HEIGHT = 300;
 const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {

+ 6 - 0
apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.vendor-styles.ts

@@ -0,0 +1,6 @@
+// @ts-nocheck -- Processed by Vite only; ?inline is a Vite-specific import suffix
+import css from 'handsontable/dist/handsontable.css?inline';
+
+const s = document.createElement('style');
+s.textContent = css;
+document.head.appendChild(s);

+ 1 - 1
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -74,7 +74,7 @@ import { EditorNavbarBottom } from './EditorNavbarBottom';
 import Preview from './Preview';
 import { useScrollSync } from './ScrollSyncHelper';
 
-import '@growi/editor/dist/style.css';
+import '../GrowiEditor.vendor-styles.prebuilt';
 
 const logger = loggerFactory('growi:PageEditor');
 

+ 1 - 1
apps/app/src/client/components/PageHistory/RevisionDiff.tsx

@@ -18,7 +18,7 @@ import { useSWRxGrowiThemeSetting } from '../../../stores/admin/customize';
 
 import styles from './RevisionDiff.module.scss';
 
-import 'diff2html/bundles/css/diff2html.min.css';
+import './RevisionDiff.vendor-styles.prebuilt';
 
 const moduleClass = styles['revision-diff-container'];
 

+ 6 - 0
apps/app/src/client/components/PageHistory/RevisionDiff.vendor-styles.ts

@@ -0,0 +1,6 @@
+// @ts-nocheck -- Processed by Vite only; ?inline is a Vite-specific import suffix
+import css from 'diff2html/bundles/css/diff2html.min.css?inline';
+
+const s = document.createElement('style');
+s.textContent = css;
+document.head.appendChild(s);

+ 1 - 1
apps/app/src/client/components/Presentation/Presentation.tsx

@@ -4,7 +4,7 @@ import {
   Presentation as PresentationSubstance,
 } from '@growi/presentation/dist/client';
 
-import '@growi/presentation/dist/style.css';
+import './Presentation.vendor-styles.prebuilt';
 
 export const Presentation = (props: PresentationProps): JSX.Element => {
   return <PresentationSubstance {...props} />;

+ 6 - 0
apps/app/src/client/components/Presentation/Presentation.vendor-styles.ts

@@ -0,0 +1,6 @@
+// @ts-nocheck -- Processed by Vite only; ?inline is a Vite-specific import suffix
+import css from '@growi/presentation/dist/style.css?inline';
+
+const s = document.createElement('style');
+s.textContent = css;
+document.head.appendChild(s);

+ 1 - 1
apps/app/src/client/components/Presentation/Slides.tsx

@@ -4,7 +4,7 @@ import {
   Slides as SlidesSubstance,
 } from '@growi/presentation/dist/client';
 
-import '@growi/presentation/dist/style.css';
+import './Presentation.vendor-styles.prebuilt';
 
 export const Slides = (props: SlidesProps): JSX.Element => {
   return <SlidesSubstance {...props} />;

+ 2 - 2
apps/app/src/client/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx

@@ -16,10 +16,10 @@ import {
 import { useShareLinkId } from '~/states/page/hooks';
 import { useIsRevisionOutdated } from '~/stores/page';
 
-import '@growi/remark-drawio/dist/style.css';
-
 import styles from './DrawioViewerWithEditButton.module.scss';
 
+import './DrawioViewerWithEditButton.vendor-styles.prebuilt';
+
 export const DrawioViewerWithEditButton = React.memo(
   (props: DrawioViewerProps): JSX.Element => {
     const { t } = useTranslation();

+ 6 - 0
apps/app/src/client/components/ReactMarkdownComponents/DrawioViewerWithEditButton.vendor-styles.ts

@@ -0,0 +1,6 @@
+// @ts-nocheck -- Processed by Vite only; ?inline is a Vite-specific import suffix
+import css from '@growi/remark-drawio/dist/style.css?inline';
+
+const s = document.createElement('style');
+s.textContent = css;
+document.head.appendChild(s);

+ 0 - 2
apps/app/src/client/components/Sidebar/Sidebar.tsx

@@ -40,8 +40,6 @@ import { ResizableAreaFallback } from './ResizableArea/ResizableAreaFallback';
 import { SidebarHead } from './SidebarHead';
 import { SidebarNav, type SidebarNavProps } from './SidebarNav';
 
-import 'simplebar-react/dist/simplebar.min.css';
-
 import styles from './Sidebar.module.scss';
 
 const SidebarContents = dynamic(

+ 9 - 0
apps/app/src/client/services/renderer/Renderer.vendor-styles.ts

@@ -0,0 +1,9 @@
+// @ts-nocheck -- Processed by Vite only; ?inline is a Vite-specific import suffix
+
+import css2 from '@growi/remark-attachment-refs/dist/client/style.css?inline';
+import css1 from '@growi/remark-lsx/dist/client/style.css?inline';
+import css3 from 'katex/dist/katex.min.css?inline';
+
+const s = document.createElement('style');
+s.textContent = css1 + css2 + css3;
+document.head.appendChild(s);

+ 1 - 2
apps/app/src/client/services/renderer/renderer.tsx

@@ -43,8 +43,7 @@ import loggerFactory from '~/utils/logger';
 
 // import EasyGrid from './PreProcessor/EasyGrid';
 
-import '@growi/remark-lsx/dist/client/style.css';
-import '@growi/remark-attachment-refs/dist/client/style.css';
+import './Renderer.vendor-styles.prebuilt';
 
 const logger = loggerFactory('growi:cli:services:renderer');
 

+ 0 - 2
apps/app/src/components/PageView/RevisionRenderer.tsx

@@ -6,8 +6,6 @@ import ReactMarkdown from 'react-markdown';
 import type { RendererOptions } from '~/interfaces/renderer-options';
 import loggerFactory from '~/utils/logger';
 
-import 'katex/dist/katex.min.css';
-
 const logger = loggerFactory('components:Page:RevisionRenderer');
 
 type Props = {

+ 27 - 8
apps/app/turbo.json

@@ -3,18 +3,27 @@
   "extends": ["//"],
   "tasks": {
 
-    "pre:styles": {
+    "pre:styles-commons": {
       "dependsOn": ["@growi/ui#build"],
       "outputs": ["src/styles/prebuilt/**"],
       "inputs": [
-        "vite.styles-prebuilt.config.ts",
+        "vite.vendor-styles-commons.ts",
         "src/styles/**/*.scss",
         "../../packages/core/scss/**"
       ],
       "outputLogs": "new-only"
     },
+    "pre:styles-components": {
+      "outputs": ["src/**/*.vendor-styles.prebuilt.js"],
+      "inputs": [
+        "vite.vendor-styles-components.ts",
+        "src/**/*.vendor-styles.ts",
+        "package.json"
+      ],
+      "outputLogs": "new-only"
+    },
     "build": {
-      "dependsOn": ["^build", "pre:styles"],
+      "dependsOn": ["^build", "pre:styles-commons", "pre:styles-components"],
       "outputs": [".next/**", "!.next/cache/**", "dist/**"],
       "inputs": [
         "next.config.js",
@@ -33,30 +42,40 @@
       "inputs": ["src/migrations/*.js"],
       "outputLogs": "new-only"
     },
-    "dev:pre:styles": {
+    "dev:pre:styles-commons": {
       "dependsOn": ["@growi/ui#dev"],
       "outputs": ["src/styles/prebuilt/**"],
       "inputs": [
-        "vite.styles-prebuilt.config.ts",
+        "vite.vendor-styles-commons.ts",
         "src/styles/**/*.scss",
         "!src/styles/prebuilt/**",
         "../../packages/core/scss/**"
       ],
       "outputLogs": "new-only"
     },
+    "dev:pre:styles-components": {
+      "outputs": ["src/**/*.vendor-styles.prebuilt.js"],
+      "inputs": [
+        "vite.vendor-styles-components.ts",
+        "src/**/*.vendor-styles.ts",
+        "!src/**/*.vendor-styles.prebuilt.js",
+        "package.json"
+      ],
+      "outputLogs": "new-only"
+    },
     "dev": {
-      "dependsOn": ["^dev", "dev:migrate", "dev:pre:styles"],
+      "dependsOn": ["^dev", "dev:migrate", "dev:pre:styles-commons", "dev:pre:styles-components"],
       "cache": false,
       "persistent": true
     },
 
     "launch-dev:ci": {
-      "dependsOn": ["^dev", "dev:migrate", "dev:pre:styles"],
+      "dependsOn": ["^dev", "dev:migrate", "dev:pre:styles-commons", "dev:pre:styles-components"],
       "cache": false
     },
 
     "lint": {
-      "dependsOn": ["^dev", "dev:pre:styles"]
+      "dependsOn": ["^dev", "dev:pre:styles-commons", "dev:pre:styles-components"]
     },
 
     "test": {

+ 0 - 0
apps/app/vite.styles-prebuilt.config.ts → apps/app/vite.vendor-styles-commons.ts


+ 32 - 0
apps/app/vite.vendor-styles-components.ts

@@ -0,0 +1,32 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { defineConfig } from 'vite';
+
+// Collect all src/**/*.vendor-styles.ts as entry points
+const entries = fs
+  .globSync('src/**/*.vendor-styles.ts', { cwd: __dirname })
+  .reduce(
+    (acc, file) => {
+      const name = file
+        .replace(/^src\//, '')
+        .replace(/\.vendor-styles\.ts$/, '.vendor-styles.prebuilt');
+      acc[name] = path.resolve(__dirname, file);
+      return acc;
+    },
+    {} as Record<string, string>,
+  );
+
+export default defineConfig({
+  publicDir: false,
+  build: {
+    outDir: 'src',
+    emptyOutDir: false,
+    rollupOptions: {
+      input: entries,
+      output: {
+        format: 'es',
+        entryFileNames: '[name].js',
+      },
+    },
+  },
+});

+ 9 - 2
biome.json

@@ -47,9 +47,16 @@
               ":BLANK_LINE:",
               "~/**",
               ":BLANK_LINE:",
-              [":PATH:", "!**/*.css", "!**/*.scss"],
+              [
+                ":PATH:",
+                "!../**/*.css",
+                "!./*.css",
+                "!../**/*.scss",
+                "!./*.scss"
+              ],
               ":BLANK_LINE:",
-              ["**/*.css", "**/*.scss"]
+              ["../**/*.scss", "../**/*.css"],
+              ["./*.scss", "./*.css"]
             ]
           }
         }