From a33e61267bdfad292488d0dc8c7d591b892fc842 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 5 Nov 2025 14:28:48 +0900 Subject: [PATCH] Fix turbo restart issue --- .changeset/cyan-actors-take.md | 5 + .../next-plugin/src/__tests__/loader.test.ts | 352 +++++++++++++++++- .../next-plugin/src/__tests__/plugin.test.ts | 3 + packages/next-plugin/src/css-loader.ts | 4 - packages/next-plugin/src/loader.ts | 46 ++- packages/next-plugin/src/plugin.ts | 5 +- 6 files changed, 393 insertions(+), 22 deletions(-) create mode 100644 .changeset/cyan-actors-take.md diff --git a/.changeset/cyan-actors-take.md b/.changeset/cyan-actors-take.md new file mode 100644 index 00000000..25eecf31 --- /dev/null +++ b/.changeset/cyan-actors-take.md @@ -0,0 +1,5 @@ +--- +'@devup-ui/next-plugin': patch +--- + +Fix turbo loader restart issue diff --git a/packages/next-plugin/src/__tests__/loader.test.ts b/packages/next-plugin/src/__tests__/loader.test.ts index 7ab9a8aa..0fca2dc7 100644 --- a/packages/next-plugin/src/__tests__/loader.test.ts +++ b/packages/next-plugin/src/__tests__/loader.test.ts @@ -1,4 +1,5 @@ -import { writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' import { join, relative } from 'node:path' import { @@ -14,6 +15,7 @@ import { } from '@devup-ui/wasm' vi.mock('@devup-ui/wasm') +vi.mock('node:fs') vi.mock('node:fs/promises') vi.mock('node:path', async (original: any) => { const origin = await original() @@ -46,6 +48,7 @@ describe('devupUILoader', () => { sheetFile: 'sheetFile', classMapFile: 'classMapFile', fileMapFile: 'fileMapFile', + themeFile: 'themeFile', watch: true, singleCss: true, }), @@ -54,6 +57,7 @@ describe('devupUILoader', () => { addDependency: vi.fn(), _compiler, } + vi.mocked(existsSync).mockReturnValue(false) vi.mocked(exportSheet).mockReturnValue('sheet') vi.mocked(exportClassMap).mockReturnValue('classMap') vi.mocked(exportFileMap).mockReturnValue('fileMap') @@ -81,11 +85,13 @@ describe('devupUILoader', () => { true, ) if (options.updatedBaseStyle) { - expect(writeFile).toHaveBeenCalledWith( - join('cssFile', 'devup-ui.css'), - 'css', - 'utf-8', - ) + await vi.waitFor(() => { + expect(writeFile).toHaveBeenCalledWith( + join('cssFile', 'devup-ui.css'), + 'css', + 'utf-8', + ) + }) } else { expect(writeFile).not.toHaveBeenCalledWith( join('cssFile', 'devup-ui.css'), @@ -113,6 +119,9 @@ describe('devupUILoader', () => { cssDir: 'cssFile', watch: false, singleCss: true, + defaultClassMap: {}, + defaultFileMap: {}, + defaultSheet: {}, }), async: vi.fn().mockReturnValue(vi.fn()), resourcePath: 'index.tsx', @@ -155,6 +164,9 @@ describe('devupUILoader', () => { cssDir: 'cssFile', watch: false, singleCss: true, + defaultClassMap: {}, + defaultFileMap: {}, + defaultSheet: {}, }), async: vi.fn().mockReturnValue(vi.fn()), resourcePath: 'index.tsx', @@ -166,7 +178,9 @@ describe('devupUILoader', () => { devupUILoader.bind(t as any)(Buffer.from('code'), 'index.tsx') expect(t.async).toHaveBeenCalled() - expect(t.async()).toHaveBeenCalledWith(new Error('error')) + await vi.waitFor(() => { + expect(t.async()).toHaveBeenCalledWith(new Error('error')) + }) }) it('should load with date now on watch', async () => { @@ -175,6 +189,10 @@ describe('devupUILoader', () => { getOptions: () => ({ package: 'package', cssDir: 'cssFile', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + themeFile: 'themeFile', watch: true, singleCss: true, }), @@ -182,6 +200,10 @@ describe('devupUILoader', () => { resourcePath: 'index.tsx', addDependency: vi.fn(), } + vi.mocked(existsSync).mockReturnValue(false) + vi.mocked(exportSheet).mockReturnValue('sheet') + vi.mocked(exportClassMap).mockReturnValue('classMap') + vi.mocked(exportFileMap).mockReturnValue('fileMap') vi.mocked(codeExtract).mockReturnValue({ code: 'code', css: 'css', @@ -203,6 +225,12 @@ describe('devupUILoader', () => { false, true, ) + await vi.waitFor(() => { + expect(writeFile).toHaveBeenCalledWith( + join('cssFile', 'cssFile'), + '/* index.tsx 0 */', + ) + }) }) it('should load with nowatch', async () => { @@ -213,6 +241,9 @@ describe('devupUILoader', () => { cssDir: './foo', watch: false, singleCss: true, + defaultClassMap: {}, + defaultFileMap: {}, + defaultSheet: {}, }), async: vi.fn().mockReturnValue(vi.fn()), resourcePath: './foo/index.tsx', @@ -229,6 +260,9 @@ describe('devupUILoader', () => { }) vi.mocked(relative).mockReturnValue('./foo/index.tsx') devupUILoader.bind(t as any)(Buffer.from('code'), '/foo/index.tsx') + await vi.waitFor(() => { + expect(t.async()).toHaveBeenCalledWith(null, 'code', null) + }) }) it('should load with theme', async () => { const { default: devupUILoader } = await import('../loader') @@ -243,6 +277,9 @@ describe('devupUILoader', () => { primary: '#000', }, }, + defaultClassMap: {}, + defaultFileMap: {}, + defaultSheet: {}, }), async: vi.fn().mockReturnValue(vi.fn()), resourcePath: 'index.tsx', @@ -264,6 +301,9 @@ describe('devupUILoader', () => { primary: '#000', }, }) + await vi.waitFor(() => { + expect(t.async()).toHaveBeenCalledWith(null, 'code', null) + }) }) it('should register theme on init', async () => { @@ -293,6 +333,15 @@ describe('devupUILoader', () => { resourcePath: 'index.tsx', addDependency: vi.fn(), } + vi.mocked(codeExtract).mockReturnValue({ + code: 'code', + css: 'css', + free: vi.fn(), + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + [Symbol.dispose]: vi.fn(), + }) devupUILoader.bind(t as any)(Buffer.from('code'), 'index.tsx') expect(registerTheme).toHaveBeenCalledTimes(1) expect(importClassMap).toHaveBeenCalledWith({ @@ -312,4 +361,293 @@ describe('devupUILoader', () => { expect(importFileMap).toHaveBeenCalledTimes(1) expect(importSheet).toHaveBeenCalledTimes(1) }) + + it('should read files when they exist in watch mode', async () => { + const { default: devupUILoader } = await import('../loader') + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssFile', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + themeFile: 'themeFile', + watch: true, + singleCss: true, + }), + async: vi.fn().mockReturnValue(vi.fn()), + resourcePath: 'index.tsx', + addDependency: vi.fn(), + } + vi.mocked(existsSync).mockImplementation((path) => { + return ( + path === 'sheetFile' || + path === 'classMapFile' || + path === 'fileMapFile' || + path === 'themeFile' + ) + }) + vi.mocked(readFile).mockResolvedValue('{}') + vi.mocked(codeExtract).mockReturnValue({ + code: 'code', + css: 'css', + free: vi.fn(), + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + [Symbol.dispose]: vi.fn(), + }) + devupUILoader.bind(t as any)(Buffer.from('code'), 'index.tsx') + + expect(existsSync).toHaveBeenCalledWith('sheetFile') + expect(existsSync).toHaveBeenCalledWith('classMapFile') + expect(existsSync).toHaveBeenCalledWith('fileMapFile') + expect(existsSync).toHaveBeenCalledWith('themeFile') + await vi.waitFor(() => { + expect(readFile).toHaveBeenCalledWith('sheetFile', 'utf-8') + expect(readFile).toHaveBeenCalledWith('classMapFile', 'utf-8') + expect(readFile).toHaveBeenCalledWith('fileMapFile', 'utf-8') + expect(readFile).toHaveBeenCalledWith('themeFile', 'utf-8') + expect(importSheet).toHaveBeenCalledWith({}) + expect(importClassMap).toHaveBeenCalledWith({}) + expect(importFileMap).toHaveBeenCalledWith({}) + expect(registerTheme).toHaveBeenCalledWith({}) + }) + }) + + it('should not read files when they do not exist in watch mode', async () => { + const { default: devupUILoader } = await import('../loader') + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssFile', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + themeFile: 'themeFile', + watch: true, + singleCss: true, + }), + async: vi.fn().mockReturnValue(vi.fn()), + resourcePath: 'index.tsx', + addDependency: vi.fn(), + } + vi.mocked(existsSync).mockReturnValue(false) + vi.mocked(codeExtract).mockReturnValue({ + code: 'code', + css: 'css', + free: vi.fn(), + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + [Symbol.dispose]: vi.fn(), + }) + devupUILoader.bind(t as any)(Buffer.from('code'), 'index.tsx') + + expect(existsSync).toHaveBeenCalledWith('sheetFile') + expect(existsSync).toHaveBeenCalledWith('classMapFile') + expect(existsSync).toHaveBeenCalledWith('fileMapFile') + expect(existsSync).toHaveBeenCalledWith('themeFile') + expect(readFile).not.toHaveBeenCalled() + }) + + it('should not write base style when watch is false even if updatedBaseStyle is true', async () => { + const { default: devupUILoader } = await import('../loader') + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssFile', + watch: false, + singleCss: true, + defaultClassMap: {}, + defaultFileMap: {}, + defaultSheet: {}, + }), + async: vi.fn().mockReturnValue(vi.fn()), + resourcePath: 'index.tsx', + addDependency: vi.fn(), + } + vi.mocked(getCss).mockReturnValue('css') + vi.mocked(codeExtract).mockReturnValue({ + code: 'code', + css: 'css', + free: vi.fn(), + map: undefined, + cssFile: undefined, + updatedBaseStyle: true, + [Symbol.dispose]: vi.fn(), + }) + devupUILoader.bind(t as any)(Buffer.from('code'), 'index.tsx') + + await vi.waitFor(() => { + expect(t.async()).toHaveBeenCalledWith(null, 'code', null) + }) + expect(writeFile).not.toHaveBeenCalledWith( + join('cssFile', 'devup-ui.css'), + 'css', + 'utf-8', + ) + }) + + it('should handle promises in error case', async () => { + const { default: devupUILoader } = await import('../loader') + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssFile', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + themeFile: 'themeFile', + watch: true, + singleCss: true, + }), + async: vi.fn().mockReturnValue(vi.fn()), + resourcePath: 'index.tsx', + addDependency: vi.fn(), + } + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFile).mockResolvedValue('{}') + vi.mocked(codeExtract).mockImplementation(() => { + throw new Error('error') + }) + devupUILoader.bind(t as any)(Buffer.from('code'), 'index.tsx') + + expect(t.async).toHaveBeenCalled() + await vi.waitFor(() => { + expect(t.async()).toHaveBeenCalledWith(new Error('error')) + }) + }) + + it('should read themeFile and register theme when theme property exists', async () => { + const { default: devupUILoader } = await import('../loader') + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssFile', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + themeFile: 'themeFile', + watch: true, + singleCss: true, + }), + async: vi.fn().mockReturnValue(vi.fn()), + resourcePath: 'index.tsx', + addDependency: vi.fn(), + } + const themeData = { + theme: { + colors: { + primary: '#000', + secondary: '#fff', + }, + }, + } + vi.mocked(existsSync).mockImplementation((path) => path === 'themeFile') + vi.mocked(readFile).mockResolvedValue(JSON.stringify(themeData)) + vi.mocked(codeExtract).mockReturnValue({ + code: 'code', + css: 'css', + free: vi.fn(), + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + [Symbol.dispose]: vi.fn(), + }) + devupUILoader.bind(t as any)(Buffer.from('code'), 'index.tsx') + + expect(existsSync).toHaveBeenCalledWith('themeFile') + await vi.waitFor(() => { + expect(readFile).toHaveBeenCalledWith('themeFile', 'utf-8') + expect(registerTheme).toHaveBeenCalledWith({ + colors: { + primary: '#000', + secondary: '#fff', + }, + }) + }) + }) + + it('should read themeFile and use empty object when theme property does not exist', async () => { + const { default: devupUILoader } = await import('../loader') + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssFile', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + themeFile: 'themeFile', + watch: true, + singleCss: true, + }), + async: vi.fn().mockReturnValue(vi.fn()), + resourcePath: 'index.tsx', + addDependency: vi.fn(), + } + const themeDataWithoutTheme = { + otherProperty: 'value', + } + vi.mocked(existsSync).mockImplementation((path) => path === 'themeFile') + vi.mocked(readFile).mockResolvedValue(JSON.stringify(themeDataWithoutTheme)) + vi.mocked(codeExtract).mockReturnValue({ + code: 'code', + css: 'css', + free: vi.fn(), + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + [Symbol.dispose]: vi.fn(), + }) + devupUILoader.bind(t as any)(Buffer.from('code'), 'index.tsx') + + expect(existsSync).toHaveBeenCalledWith('themeFile') + await vi.waitFor(() => { + expect(readFile).toHaveBeenCalledWith('themeFile', 'utf-8') + expect(registerTheme).toHaveBeenCalledWith({}) + }) + }) + + it('should read themeFile and use empty object when theme property is null', async () => { + const { default: devupUILoader } = await import('../loader') + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssFile', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + themeFile: 'themeFile', + watch: true, + singleCss: true, + }), + async: vi.fn().mockReturnValue(vi.fn()), + resourcePath: 'index.tsx', + addDependency: vi.fn(), + } + const themeDataWithNullTheme = { + theme: null, + } + vi.mocked(existsSync).mockImplementation((path) => path === 'themeFile') + vi.mocked(readFile).mockResolvedValue( + JSON.stringify(themeDataWithNullTheme), + ) + vi.mocked(codeExtract).mockReturnValue({ + code: 'code', + css: 'css', + free: vi.fn(), + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + [Symbol.dispose]: vi.fn(), + }) + devupUILoader.bind(t as any)(Buffer.from('code'), 'index.tsx') + + expect(existsSync).toHaveBeenCalledWith('themeFile') + await vi.waitFor(() => { + expect(readFile).toHaveBeenCalledWith('themeFile', 'utf-8') + expect(registerTheme).toHaveBeenCalledWith({}) + }) + }) }) diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 024ff63e..a6c77ae9 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -119,6 +119,7 @@ describe('DevupUINextPlugin', () => { sheetFile: join('df', 'sheet.json'), classMapFile: join('df', 'classMap.json'), fileMapFile: join('df', 'fileMap.json'), + themeFile: 'devup.json', watch: false, singleCss: false, theme: {}, @@ -205,6 +206,7 @@ describe('DevupUINextPlugin', () => { keyframes: {}, properties: {}, }, + themeFile: 'devup.json', }, }, ], @@ -273,6 +275,7 @@ describe('DevupUINextPlugin', () => { keyframes: {}, properties: {}, }, + themeFile: 'devup.json', }, }, ], diff --git a/packages/next-plugin/src/css-loader.ts b/packages/next-plugin/src/css-loader.ts index 0349b2a1..a0ff26d7 100644 --- a/packages/next-plugin/src/css-loader.ts +++ b/packages/next-plugin/src/css-loader.ts @@ -8,10 +8,6 @@ function getFileNumByFilename(filename: string) { export interface DevupUICssLoaderOptions { // turbo - theme: object - defaultSheet: object - defaultClassMap: object - defaultFileMap: object watch: boolean } diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts index bf37bca7..8f037377 100644 --- a/packages/next-plugin/src/loader.ts +++ b/packages/next-plugin/src/loader.ts @@ -1,4 +1,5 @@ -import { writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' import { basename, dirname, join, relative } from 'node:path' import { @@ -20,6 +21,7 @@ export interface DevupUILoaderOptions { sheetFile: string classMapFile: string fileMapFile: string + themeFile: string watch: boolean singleCss: boolean // turbo @@ -39,18 +41,47 @@ const devupUILoader: RawLoaderDefinitionFunction = sheetFile, classMapFile, fileMapFile, + themeFile, singleCss, theme, defaultClassMap, defaultFileMap, defaultSheet, } = this.getOptions() + + const promises: Promise[] = [] if (!init) { init = true - importFileMap(defaultFileMap) - importClassMap(defaultClassMap) - importSheet(defaultSheet) - registerTheme(theme) + if (watch) { + // restart loader issue + // loader should read files when they exist in watch mode + if (existsSync(sheetFile)) + promises.push( + readFile(sheetFile, 'utf-8').then(JSON.parse).then(importSheet), + ) + if (existsSync(classMapFile)) + promises.push( + readFile(classMapFile, 'utf-8') + .then(JSON.parse) + .then(importClassMap), + ) + if (existsSync(fileMapFile)) + promises.push( + readFile(fileMapFile, 'utf-8').then(JSON.parse).then(importFileMap), + ) + if (existsSync(themeFile)) + promises.push( + readFile(themeFile, 'utf-8') + .then(JSON.parse) + .then((data) => data?.['theme'] ?? {}) + .then(registerTheme), + ) + } else { + importFileMap(defaultFileMap) + importClassMap(defaultClassMap) + importSheet(defaultSheet) + registerTheme(theme) + } } const callback = this.async() @@ -71,7 +102,6 @@ const devupUILoader: RawLoaderDefinitionFunction = true, ) const sourceMap = map ? JSON.parse(map) : null - const promises: Promise[] = [] if (updatedBaseStyle && watch) { // update base style promises.push( @@ -94,7 +124,9 @@ const devupUILoader: RawLoaderDefinitionFunction = .catch(console.error) .finally(() => callback(null, code, sourceMap)) } catch (error) { - callback(error as Error) + Promise.all(promises) + .catch(console.error) + .finally(() => callback(error as Error)) } return } diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 3abea3f2..2b67eee4 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -37,10 +37,6 @@ export function DevupUI( process.env.TURBOPACK === '1' || process.env.TURBOPACK === 'auto' // turbopack is now stable, TURBOPACK is set to auto without any flags if (isTurbo) { - // if (process.env.NODE_ENV === 'production') { - // throw new Error('Devup UI is not supported in production with turbopack') - // } - config ??= {} config.turbopack ??= {} config.turbopack.rules ??= {} @@ -129,6 +125,7 @@ export function DevupUI( sheetFile, classMapFile, fileMapFile, + themeFile: devupFile, defaultSheet, defaultClassMap, defaultFileMap,