Skip to content
Draft
10 changes: 10 additions & 0 deletions packages/bippy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@
"default": "./dist/source.cjs"
}
},
"./hmr": {
"import": {
"types": "./dist/hmr.d.ts",
"default": "./dist/hmr.js"
},
"require": {
"types": "./dist/hmr.d.cts",
"default": "./dist/hmr.cjs"
}
},
"./dist/*": "./dist/*.js",
"./dist/*.js": "./dist/*.js",
"./dist/*.cjs": "./dist/*.cjs"
Expand Down
4 changes: 4 additions & 0 deletions packages/bippy/src/hmr/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './types.js';
export * from './primitives.js';
export * from './vite-refresh-runtime.js';
export * from './next-refresh-runtime.js';
41 changes: 41 additions & 0 deletions packages/bippy/src/hmr/next-refresh-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { isObjectRecord } from './primitives.js';
import { NextRefreshHelpers } from './types.js';

export const isNextRefreshHelpers = (
maybeNextRefreshHelpers: unknown,
): maybeNextRefreshHelpers is NextRefreshHelpers => {
if (!isObjectRecord(maybeNextRefreshHelpers)) {
return false;
}

const maybeGetRefreshBoundarySignature =
maybeNextRefreshHelpers['getRefreshBoundarySignature'];
const maybeIsReactRefreshBoundary =
maybeNextRefreshHelpers['isReactRefreshBoundary'];
const maybeRegisterExportsForReactRefresh =
maybeNextRefreshHelpers['registerExportsForReactRefresh'];
const maybeScheduleUpdate = maybeNextRefreshHelpers['scheduleUpdate'];
const maybeShouldInvalidateReactRefreshBoundary =
maybeNextRefreshHelpers['shouldInvalidateReactRefreshBoundary'];

return (
typeof maybeGetRefreshBoundarySignature === 'function' &&
typeof maybeIsReactRefreshBoundary === 'function' &&
typeof maybeRegisterExportsForReactRefresh === 'function' &&
typeof maybeScheduleUpdate === 'function' &&
typeof maybeShouldInvalidateReactRefreshBoundary === 'function'
);
};

export const getNextRefreshHelpers = (): NextRefreshHelpers | null => {
if (!isObjectRecord(globalThis)) {
return null;
}

const maybeRefreshHelpers = globalThis['$RefreshHelpers$'];
if (!isNextRefreshHelpers(maybeRefreshHelpers)) {
return null;
}

return maybeRefreshHelpers;
};
113 changes: 113 additions & 0 deletions packages/bippy/src/hmr/primitives.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { normalizeFileName, parseStack } from '../source/index.js';

import { ExtractModulePathFromStackOptions } from './types.js';

export const isObjectRecord = (
maybeObjectRecord: unknown,
): maybeObjectRecord is Record<string, unknown> => {
return typeof maybeObjectRecord === 'object' && maybeObjectRecord !== null;
};

export const mapFromRecord = <Value>(
recordObject: Record<string, Value>,
): Map<string, Value> => {
return new Map<string, Value>(Object.entries(recordObject));
};

export const mapToRecord = <Value>(
valueByKeyMap: Map<string, Value>,
): Record<string, Value> => {
return Object.fromEntries(valueByKeyMap.entries());
};

export const createTextHash = (inputText: string): string => {
let hashValue = 2166136261;
for (let characterIndex = 0; characterIndex < inputText.length; characterIndex += 1) {
hashValue ^= inputText.charCodeAt(characterIndex);
hashValue +=
(hashValue << 1) +
(hashValue << 4) +
(hashValue << 7) +
(hashValue << 8) +
(hashValue << 24);
}
return (hashValue >>> 0).toString(36);
};

export const getComponentTypeFingerprint = (
familyId: string,
componentType: unknown,
): string => {
const componentTypeSignature =
typeof componentType === 'function' || typeof componentType === 'object'
? String(componentType)
: typeof componentType;
return createTextHash(`${familyId}::${componentTypeSignature}`);
};

export const createFamilyId = (
modulePath: string,
registrationId: string,
): string => {
return `${modulePath}::${registrationId}`;
};

export const normalizeRefreshModulePath = (
maybeRefreshModulePath: unknown,
): string | null => {
if (typeof maybeRefreshModulePath === 'string') {
return maybeRefreshModulePath;
}

if (
typeof maybeRefreshModulePath === 'number' &&
Number.isFinite(maybeRefreshModulePath)
) {
return String(maybeRefreshModulePath);
}

return null;
};

export const extractModulePathFromStack = (
errorStack: string | undefined,
options?: ExtractModulePathFromStackOptions,
): string | null => {
if (!errorStack) {
return null;
}

const ignoredModulePathSet = new Set(options?.ignoredModulePaths ?? []);
const sourcePathPrefixes =
options?.sourcePathPrefixes ??
(options?.sourcePathPrefix ? [options.sourcePathPrefix] : ['/src/']);
const modulePathPredicate = options?.modulePathPredicate;
const stackFrames = parseStack(errorStack, { includeInElement: false });
for (const stackFrame of stackFrames) {
if (!stackFrame.fileName) {
continue;
}

const normalizedFileName = normalizeFileName(stackFrame.fileName);
if (modulePathPredicate) {
if (
modulePathPredicate(normalizedFileName) &&
!ignoredModulePathSet.has(normalizedFileName)
) {
return normalizedFileName;
}
continue;
}

if (
sourcePathPrefixes.some((sourcePathPrefix) =>
normalizedFileName.startsWith(sourcePathPrefix),
) &&
!ignoredModulePathSet.has(normalizedFileName)
) {
return normalizedFileName;
}
}

return null;
};
31 changes: 31 additions & 0 deletions packages/bippy/src/hmr/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export interface RefreshRuntime {
getRefreshReg: (
filename: string,
) => (componentType: unknown, registrationId: string) => void;
validateRefreshBoundaryAndEnqueueUpdate: (
moduleId: string,
previousExports: Record<string, unknown>,
nextExports: Record<string, unknown>,
) => string | undefined;
}

export interface NextRefreshHelpers {
getRefreshBoundarySignature: (moduleExports: unknown) => unknown[];
isReactRefreshBoundary: (moduleExports: unknown) => boolean;
registerExportsForReactRefresh: (
moduleExports: unknown,
moduleId: string | number,
) => void;
scheduleUpdate: () => void;
shouldInvalidateReactRefreshBoundary: (
previousSignature: unknown[],
nextSignature: unknown[],
) => boolean;
}

export interface ExtractModulePathFromStackOptions {
ignoredModulePaths?: string[];
modulePathPredicate?: (normalizedFileName: string) => boolean;
sourcePathPrefix?: string;
sourcePathPrefixes?: string[];
}
39 changes: 39 additions & 0 deletions packages/bippy/src/hmr/vite-refresh-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { isObjectRecord } from './primitives.js';
import { RefreshRuntime } from './types.js';

export const isRefreshRuntime = (
maybeRefreshRuntime: unknown,
): maybeRefreshRuntime is RefreshRuntime => {
if (!isObjectRecord(maybeRefreshRuntime)) {
return false;
}

const maybeGetRefreshReg = maybeRefreshRuntime['getRefreshReg'];
const maybeValidateRefreshBoundaryAndEnqueueUpdate =
maybeRefreshRuntime['validateRefreshBoundaryAndEnqueueUpdate'];

return (
typeof maybeGetRefreshReg === 'function' &&
typeof maybeValidateRefreshBoundaryAndEnqueueUpdate === 'function'
);
};

export const loadViteRefreshRuntime = async (): Promise<RefreshRuntime | null> => {
try {
const runtimeModulePath = ['/', '@react-refresh'].join('');
const importRuntimeModule = new Function(
'modulePath',
'return import(modulePath)',
);
const runtimeModuleImportResult: unknown =
await importRuntimeModule(runtimeModulePath);

if (!isRefreshRuntime(runtimeModuleImportResult)) {
return null;
}

return runtimeModuleImportResult;
} catch {
return null;
}
};
12 changes: 8 additions & 4 deletions packages/bippy/src/test/instrument.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,16 @@ it('onPostCommitFiberRoot is called', () => {
currentFiberRoot = fiberRoot;
});
instrument({ onPostCommitFiberRoot });
expect(onPostCommitFiberRoot).not.toHaveBeenCalled();
const callCountBeforeFirstRender = onPostCommitFiberRoot.mock.calls.length;
render(<Example />);
expect(onPostCommitFiberRoot).not.toHaveBeenCalled();
// onPostCommitFiberRoot only called when there is a fiber root
const callCountBeforeSecondRender = onPostCommitFiberRoot.mock.calls.length;
expect(callCountBeforeSecondRender).toBeGreaterThanOrEqual(
callCountBeforeFirstRender,
);
render(<ExampleWithEffect />);
expect(onPostCommitFiberRoot).toHaveBeenCalled();
expect(onPostCommitFiberRoot.mock.calls.length).toBeGreaterThan(
callCountBeforeSecondRender,
);
expect(currentFiberRoot?.current.child.type).toBe(ExampleWithEffect);
});

Expand Down
14 changes: 13 additions & 1 deletion packages/bippy/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import fs from 'node:fs';
import { defineConfig, type Options } from 'tsdown';

const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));

const DEFAULT_OPTIONS: Options = {
clean: false,
dts: true,
entry: [],
env: {
NODE_ENV: process.env.NODE_ENV ?? 'development',
VERSION: JSON.parse(fs.readFileSync('package.json', 'utf8')).version,
},
inputOptions: (options) => ({
...options,
transform: {
...options.transform,
define: {
...(options.transform as Record<string, any>)?.define,
'process.env.VERSION': JSON.stringify(pkg.version),
},
},
}),
external: ['react', 'react-dom', 'react-reconciler'],
format: [],
minify: process.env.NODE_ENV === 'production',
Expand All @@ -28,6 +39,7 @@ export default defineConfig([
index: './src/index.ts',
core: './src/core.ts',
source: './src/source/index.ts',
hmr: './src/hmr/index.ts',
['install-hook-only']: './src/install-hook-only.ts',
},
format: ['esm', 'cjs'],
Expand Down
1 change: 1 addition & 0 deletions packages/next-playground/instrumentation-client.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
import 'bippy/install-hook-only';
import '../vite-playground/src/hmr-versioning-prototype';
4 changes: 3 additions & 1 deletion packages/next-playground/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
/* config options here */
experimental: {
externalDir: true,
},
};

export default nextConfig;
4 changes: 2 additions & 2 deletions packages/vite-playground/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Inspector } from '@bippy/next-playground/components/inspector';
import { TodoList } from '@bippy/next-playground/components/todo-list';
import { Inspector } from "@bippy/next-playground/components/inspector";
import { TodoList } from "@bippy/next-playground/components/todo-list";

export default function App() {
return (
Expand Down
Loading
Loading