Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 111 additions & 61 deletions packages/core/src/tools/codegen/import-map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ describe('Import Map Generation', () => {
const sandbox = sinon.createSandbox();
afterEach(() => {
sandbox.restore();
delete process.env.CODEGEN_CLIENT_DIRECTIVE_PREFIX_BYTES;
/* eslint-disable-next-line */
importUnitMocks.getImportMap = importUnitMocks.getImportMap;
/* eslint-disable-next-line */
Expand Down Expand Up @@ -423,26 +424,16 @@ describe('Import Map Generation', () => {
getImportMapStub.returns(new Map());
const fsWriteStub = sandbox.stub(require('fs'), 'writeFileSync');

// Mock fs.createReadStream for prepImportMaps with proper event emitter
const createReadStreamStub = sandbox.stub(fs, 'createReadStream');
createReadStreamStub.callsFake(() => {
const handlers: any = {};
const mockStream = {
on: (event: string, handler: any) => {
handlers[event] = handler;
if (event === 'error') {
// Don't trigger error
} else if (event === 'close') {
// Trigger close after data
setImmediate(() => handler());
}
return mockStream;
},
};
// Trigger data event
setImmediate(() => handlers.data && handlers.data(Buffer.from('import { funco')));
return mockStream as any;
});
// Mock fs.promises.open for prepImportMaps client component detection
const openStub = sandbox.stub(fs.promises, 'open');
openStub.resolves({
read: async (buffer: Buffer) => {
const payload = Buffer.from('import { funco', 'utf8');
payload.copy(buffer, 0, 0, payload.length);
return { bytesRead: payload.length, buffer } as any;
},
close: async () => undefined,
} as any);

defaultMapTemplateStub.returns('// import map content');
await run({ paths: ['foo'], exclude: ['bar'], scConfig });
Expand Down Expand Up @@ -505,26 +496,16 @@ ${defaultMapTemplate(indexedImportMap)}`;
getImportMapStub.returns(new Map());
const fsWriteStub = sandbox.stub(require('fs'), 'writeFileSync');

// Mock fs.createReadStream for prepImportMaps with proper event emitter
const createReadStreamStub = sandbox.stub(fs, 'createReadStream');
createReadStreamStub.callsFake(() => {
const handlers: any = {};
const mockStream = {
on: (event: string, handler: any) => {
handlers[event] = handler;
if (event === 'error') {
// Don't trigger error
} else if (event === 'close') {
// Trigger close after data
setImmediate(() => handler());
}
return mockStream;
},
};
// Trigger data event
setImmediate(() => handlers.data && handlers.data(Buffer.from('import { funco')));
return mockStream as any;
});
// Mock fs.promises.open for prepImportMaps client component detection
const openStub = sandbox.stub(fs.promises, 'open');
openStub.resolves({
read: async (buffer: Buffer) => {
const payload = Buffer.from('import { funco', 'utf8');
payload.copy(buffer, 0, 0, payload.length);
return { bytesRead: payload.length, buffer } as any;
},
close: async () => undefined,
} as any);

defaultMapTemplateStub.returns('// import map content');

Expand All @@ -547,26 +528,16 @@ ${defaultMapTemplate(indexedImportMap)}`;
getImportMapStub.returns(new Map());
const error = new Error('Unit test mocks: write failed');

// Mock fs.createReadStream for prepImportMaps with proper event emitter
const createReadStreamStub = sandbox.stub(fs, 'createReadStream');
createReadStreamStub.callsFake(() => {
const handlers: any = {};
const mockStream = {
on: (event: string, handler: any) => {
handlers[event] = handler;
if (event === 'error') {
// Don't trigger error
} else if (event === 'close') {
// Trigger close after data
setImmediate(() => handler());
}
return mockStream;
},
};
// Trigger data event
setImmediate(() => handlers.data && handlers.data(Buffer.from('import { funco')));
return mockStream as any;
});
// Mock fs.promises.open for prepImportMaps client component detection
const openStub = sandbox.stub(fs.promises, 'open');
openStub.resolves({
read: async (buffer: Buffer) => {
const payload = Buffer.from('import { funco', 'utf8');
payload.copy(buffer, 0, 0, payload.length);
return { bytesRead: payload.length, buffer } as any;
},
close: async () => undefined,
} as any);

sandbox.stub(require('fs'), 'writeFileSync').throws(error);
defaultMapTemplateStub.returns('// import map content');
Expand All @@ -579,8 +550,87 @@ ${defaultMapTemplate(indexedImportMap)}`;
}
expect(thrownError).to.equal(error);
});

it("should detect 'use client' with leading whitespace/comments/BOM and optional semicolon", async () => {
const scConfig = { disableCodeGeneration: false } as any;
utilsUnitMocks.xmCloudDeploy = sandbox.stub().returns(true) as any;

const fakeEntries = [{ filePath: 'a.tsx' }, { filePath: 'b.tsx' }];
getComponentListStub.returns(fakeEntries);

const openStub = sandbox.stub(fs.promises, 'open');
openStub.callsFake(async (filePath: any) => {
const normalized = String(filePath).replace(/\\/g, '/');
const content = normalized.endsWith('/a.tsx')
? "\ufeff\n// comment\n/* block */\n 'use client';\nexport default function A() { return null; }"
: '\n /* comment */\n "use client"\nexport default function B() { return null; }';

return {
read: async (buffer: Buffer) => {
const payload = Buffer.from(content, 'utf8');
payload.copy(buffer, 0, 0, Math.min(payload.length, buffer.length));
return { bytesRead: Math.min(payload.length, buffer.length), buffer } as any;
},
close: async () => undefined,
} as any;
});

const importMapServer = new Map<string, ModuleExports>();
const importMapClient = new Map<string, ModuleExports>();
getImportMapStub.onCall(0).returns(importMapServer);
getImportMapStub.onCall(1).returns(importMapClient);
defaultMapTemplateStub.returns('// import map content');
const fsWriteStub = sandbox.stub(require('fs'), 'writeFileSync');

await run({ paths: ['foo'], exclude: [], scConfig, separateServerClientMaps: true });

const appFolder = process.cwd();
const aFullPath = path.resolve(appFolder, 'a.tsx');
const bFullPath = path.resolve(appFolder, 'b.tsx');

expect(getImportMapStub.calledTwice).to.be.true;
expect(getImportMapStub.getCall(0).args[0]).to.deep.equal([]);
expect(getImportMapStub.getCall(1).args[0]).to.deep.equal([aFullPath, bFullPath]);
expect(fsWriteStub.calledTwice).to.be.true;
});

it("should honor CODEGEN_CLIENT_DIRECTIVE_PREFIX_BYTES when detecting 'use client'", async () => {
const scConfig = { disableCodeGeneration: false } as any;
utilsUnitMocks.xmCloudDeploy = sandbox.stub().returns(true) as any;

const fakeEntries = [{ filePath: 'late.tsx' }];
getComponentListStub.returns(fakeEntries);

process.env.CODEGEN_CLIENT_DIRECTIVE_PREFIX_BYTES = '64';
const openStub = sandbox.stub(fs.promises, 'open');
openStub.callsFake(async () => {
const prefix = ' '.repeat(120);
const content = `${prefix}'use client';\nexport default function Late() { return null; }`;
return {
read: async (buffer: Buffer, offset?: number, length?: number) => {
expect(buffer.length).to.equal(64);
const payload = Buffer.from(content, 'utf8');
payload.copy(buffer, 0, 0, Math.min(payload.length, buffer.length));
return { bytesRead: Math.min(payload.length, buffer.length), buffer } as any;
},
close: async () => undefined,
} as any;
});

getImportMapStub.onCall(0).returns(new Map());
getImportMapStub.onCall(1).returns(new Map());
defaultMapTemplateStub.returns('// import map content');
sandbox.stub(require('fs'), 'writeFileSync');

await run({ paths: ['foo'], exclude: [], scConfig, separateServerClientMaps: true });

const appFolder = process.cwd();
const lateFullPath = path.resolve(appFolder, 'late.tsx');
expect(getImportMapStub.calledTwice).to.be.true;
expect(getImportMapStub.getCall(0).args[0]).to.deep.equal([lateFullPath]);
expect(getImportMapStub.getCall(1).args[0]).to.deep.equal([]);
});
});
});
});
});

54 changes: 39 additions & 15 deletions packages/core/src/tools/codegen/import-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,23 +368,10 @@ const prepImportMaps = async (paths: string[], separateMaps?: boolean): Promise<
const importMapFileClient = path.join(process.cwd(), '.sitecore', 'import-map.client.ts');
const importMapFileServer = path.join(process.cwd(), '.sitecore', 'import-map.server.ts');
for (const componentPath of paths) {
const fullPath = path.isAbsolute(componentPath)
const fullPath: string = path.isAbsolute(componentPath)
? componentPath
: path.resolve(appPath, componentPath);
// read the start of the file that may be 'use client'
const firstLine = await new Promise<string>((resolve) => {
let readBuffer = '';
const stream = fs.createReadStream(fullPath, { end: 12 });
stream
.on('data', async (chunk) => {
readBuffer += chunk.toString();
})
.on('close', () => resolve(readBuffer))
.on('error', () => resolve(''));
});

if (!firstLine) continue;
if (firstLine.match(/['"]use client['"]/)) {
if (await isClientComponent(fullPath)) {
clientPaths.push(fullPath);
} else {
serverPaths.push(fullPath);
Expand All @@ -396,6 +383,43 @@ const prepImportMaps = async (paths: string[], separateMaps?: boolean): Promise<
];
};

async function isClientComponent(fullPath: string) {
// Read a small prefix (fast) but large enough to include any BOM/leading comments and the directive.
// 512B is typically sufficient and avoids reading entire files during codegen.
// Can be overridden via env var CODEGEN_CLIENT_DIRECTIVE_PREFIX_BYTES.
const envPrefixBytes = Number.parseInt(
process.env.CODEGEN_CLIENT_DIRECTIVE_PREFIX_BYTES || '',
10
);
const PREFIX_BYTES = Number.isFinite(envPrefixBytes)
? Math.min(Math.max(envPrefixBytes, 64), 8192)
: 512;
let fileHandle: fs.promises.FileHandle | undefined;

try {
fileHandle = await fs.promises.open(fullPath, 'r');
const buffer = Buffer.alloc(PREFIX_BYTES);
const { bytesRead } = await fileHandle.read(buffer, 0, PREFIX_BYTES, 0);
if (bytesRead <= 0) return false;

// Strip UTF-8 BOM if present.
let content = buffer.toString('utf8', 0, bytesRead);
if (content.charCodeAt(0) === 0xfeff) {
content = content.slice(1);
}

// Detect 'use client' directive as the first executable statement.
// Allow leading whitespace/newlines and any number of leading comments.
// Accept optional trailing semicolon.
const clientRegex = /^\s*(?:\/\/[^\n]*(?:\r?\n)|\/\*[\s\S]*?\*\/\s*)*['"]use client['"]\s*;?/;
return clientRegex.test(content);
} catch {
return false;
} finally {
await fileHandle?.close();
}
}

/**
* Entry point function for generating import-map. Parses provided paths and outputs the modules and imports from those files into .sitecore/import-map.ts
* @param {WriteImportMapArgsInternal} args include/exclude paths settings to be processed for import-map, and the Sitecore configuration.
Expand Down