diff --git a/packages/bippy/package.json b/packages/bippy/package.json index dfff34b4..5d472e91 100644 --- a/packages/bippy/package.json +++ b/packages/bippy/package.json @@ -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" diff --git a/packages/bippy/src/hmr/index.ts b/packages/bippy/src/hmr/index.ts new file mode 100644 index 00000000..ed6d680f --- /dev/null +++ b/packages/bippy/src/hmr/index.ts @@ -0,0 +1,4 @@ +export * from './types.js'; +export * from './primitives.js'; +export * from './vite-refresh-runtime.js'; +export * from './next-refresh-runtime.js'; diff --git a/packages/bippy/src/hmr/next-refresh-runtime.ts b/packages/bippy/src/hmr/next-refresh-runtime.ts new file mode 100644 index 00000000..fd1e9116 --- /dev/null +++ b/packages/bippy/src/hmr/next-refresh-runtime.ts @@ -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; +}; diff --git a/packages/bippy/src/hmr/primitives.ts b/packages/bippy/src/hmr/primitives.ts new file mode 100644 index 00000000..dde93aa0 --- /dev/null +++ b/packages/bippy/src/hmr/primitives.ts @@ -0,0 +1,113 @@ +import { normalizeFileName, parseStack } from '../source/index.js'; + +import { ExtractModulePathFromStackOptions } from './types.js'; + +export const isObjectRecord = ( + maybeObjectRecord: unknown, +): maybeObjectRecord is Record => { + return typeof maybeObjectRecord === 'object' && maybeObjectRecord !== null; +}; + +export const mapFromRecord = ( + recordObject: Record, +): Map => { + return new Map(Object.entries(recordObject)); +}; + +export const mapToRecord = ( + valueByKeyMap: Map, +): Record => { + 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; +}; diff --git a/packages/bippy/src/hmr/types.ts b/packages/bippy/src/hmr/types.ts new file mode 100644 index 00000000..12ceb020 --- /dev/null +++ b/packages/bippy/src/hmr/types.ts @@ -0,0 +1,31 @@ +export interface RefreshRuntime { + getRefreshReg: ( + filename: string, + ) => (componentType: unknown, registrationId: string) => void; + validateRefreshBoundaryAndEnqueueUpdate: ( + moduleId: string, + previousExports: Record, + nextExports: Record, + ) => 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[]; +} diff --git a/packages/bippy/src/hmr/vite-refresh-runtime.ts b/packages/bippy/src/hmr/vite-refresh-runtime.ts new file mode 100644 index 00000000..55acbb37 --- /dev/null +++ b/packages/bippy/src/hmr/vite-refresh-runtime.ts @@ -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 => { + 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; + } +}; diff --git a/packages/bippy/src/test/instrument.test.tsx b/packages/bippy/src/test/instrument.test.tsx index f815aab8..0c9b4675 100644 --- a/packages/bippy/src/test/instrument.test.tsx +++ b/packages/bippy/src/test/instrument.test.tsx @@ -58,12 +58,16 @@ it('onPostCommitFiberRoot is called', () => { currentFiberRoot = fiberRoot; }); instrument({ onPostCommitFiberRoot }); - expect(onPostCommitFiberRoot).not.toHaveBeenCalled(); + const callCountBeforeFirstRender = onPostCommitFiberRoot.mock.calls.length; render(); - expect(onPostCommitFiberRoot).not.toHaveBeenCalled(); - // onPostCommitFiberRoot only called when there is a fiber root + const callCountBeforeSecondRender = onPostCommitFiberRoot.mock.calls.length; + expect(callCountBeforeSecondRender).toBeGreaterThanOrEqual( + callCountBeforeFirstRender, + ); render(); - expect(onPostCommitFiberRoot).toHaveBeenCalled(); + expect(onPostCommitFiberRoot.mock.calls.length).toBeGreaterThan( + callCountBeforeSecondRender, + ); expect(currentFiberRoot?.current.child.type).toBe(ExampleWithEffect); }); diff --git a/packages/bippy/tsdown.config.ts b/packages/bippy/tsdown.config.ts index 3883e3a3..02ab8ec3 100644 --- a/packages/bippy/tsdown.config.ts +++ b/packages/bippy/tsdown.config.ts @@ -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)?.define, + 'process.env.VERSION': JSON.stringify(pkg.version), + }, + }, + }), external: ['react', 'react-dom', 'react-reconciler'], format: [], minify: process.env.NODE_ENV === 'production', @@ -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'], diff --git a/packages/next-playground/instrumentation-client.ts b/packages/next-playground/instrumentation-client.ts index c03bd7c9..0f673b37 100644 --- a/packages/next-playground/instrumentation-client.ts +++ b/packages/next-playground/instrumentation-client.ts @@ -1 +1,2 @@ import 'bippy/install-hook-only'; +import '../vite-playground/src/hmr-versioning-prototype'; diff --git a/packages/next-playground/next.config.ts b/packages/next-playground/next.config.ts index 5e891cf0..73d03ef4 100644 --- a/packages/next-playground/next.config.ts +++ b/packages/next-playground/next.config.ts @@ -1,7 +1,9 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { - /* config options here */ + experimental: { + externalDir: true, + }, }; export default nextConfig; diff --git a/packages/vite-playground/src/App.tsx b/packages/vite-playground/src/App.tsx index ffd0c737..02022e90 100644 --- a/packages/vite-playground/src/App.tsx +++ b/packages/vite-playground/src/App.tsx @@ -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 ( diff --git a/packages/vite-playground/src/hmr-versioning-prototype.ts b/packages/vite-playground/src/hmr-versioning-prototype.ts new file mode 100644 index 00000000..1774ac9b --- /dev/null +++ b/packages/vite-playground/src/hmr-versioning-prototype.ts @@ -0,0 +1,1088 @@ +import { + createFamilyId, + extractModulePathFromStack, + getNextRefreshHelpers, + getComponentTypeFingerprint, + isObjectRecord, + loadViteRefreshRuntime, + normalizeRefreshModulePath, + mapFromRecord, + mapToRecord, +} from 'bippy/hmr'; + +interface RefreshRuntime { + getRefreshReg: ( + filename: string, + ) => (componentType: unknown, registrationId: string) => void; + validateRefreshBoundaryAndEnqueueUpdate: ( + moduleId: string, + previousExports: Record, + nextExports: Record, + ) => string | undefined; +} + +interface NextRefreshHelpers { + scheduleUpdate: () => void; +} + +interface PendingFamilyChange { + familyId: string; + nextTypeFingerprint: string; + nextType: unknown; + previousTypeFingerprint: string | undefined; + previousType: unknown; +} + +interface FamilyRegistrationMetadata { + familyId: string; + modulePath: string; + registrationId: string; +} + +interface VersionSnapshot { + changedFamilyIds: string[]; + familyTypeFingerprintById: Map; + familyTypeById: Map; + timestamp: number; + version: number; +} + +interface VersionSummary { + changedFamilyIds: string[]; + familyCount: number; + timestamp: number; + version: number; +} + +interface ParsedFamilyId { + modulePath: string; + registrationId: string; +} + +interface ChangedFamilyDerivation { + componentContent: string; + familyId: string; + modulePath: string; + registrationId: string; +} + +interface CheckpointDerivation { + changedFamilies: ChangedFamilyDerivation[]; + changedModulePaths: string[]; + timestamp: number; + version: number; +} + +interface HmrVersioningInternalState { + activeRefreshModulePath: string | null; + componentTypeByFingerprint: Map; + currentVersionIndex: number; + familyFirstSeenVersionById: Map; + familyRegistrationMetadataByFamilyId: Map; + isApplyingVersion: boolean; + isBeforePerformHookInstalled: boolean; + isNextScheduleHookInstalled: boolean; + isRefreshInterceptTrackingInstalled: boolean; + isRefreshRegTrackingInstalled: boolean; + moduleFallbackCounter: number; + pendingFamilyChangeById: Map; + refreshRegistrationHandlerByModulePath: Map< + string, + (componentType: unknown, registrationId: string) => void + >; + timelineSnapshots: VersionSnapshot[]; +} + +interface PersistedVersionSnapshot { + changedFamilyIds: string[]; + familyTypeFingerprintById: Record; + timestamp: number; + version: number; +} + +interface PersistedHmrVersioningState { + familyFirstSeenVersionById: Record; + persistedAtTimestamp: number; + timelineSnapshots: PersistedVersionSnapshot[]; +} + +interface HmrVersioningController { + getCheckpointDerivations: () => CheckpointDerivation[]; + getCurrentVersion: () => VersionSummary; + getTimeline: () => VersionSummary[]; + jumpToVersion: (targetVersionIndex: number) => boolean; + redo: () => boolean; + undo: () => boolean; +} + +declare global { + interface Window { + $RefreshHelpers$?: unknown; + $RefreshInterceptModuleExecution$?: unknown; + $RefreshReg$?: unknown; + __BIPPY_HMR_VERSIONING__?: HmrVersioningController; + __BIPPY_HMR_VERSIONING_INTERNAL_STATE__?: HmrVersioningInternalState; + __registerBeforePerformReactRefresh?: ( + callback: () => unknown, + ) => unknown; + } +} + +const createInitialVersionSnapshot = (): VersionSnapshot => { + return { + changedFamilyIds: [], + familyTypeFingerprintById: new Map(), + familyTypeById: new Map(), + timestamp: Date.now(), + version: 0, + }; +}; + +const createVersionSummary = (versionSnapshot: VersionSnapshot): VersionSummary => { + return { + changedFamilyIds: [...versionSnapshot.changedFamilyIds], + familyCount: versionSnapshot.familyTypeById.size, + timestamp: versionSnapshot.timestamp, + version: versionSnapshot.version, + }; +}; + +const parseFamilyId = (familyId: string): ParsedFamilyId => { + const separatorIndex = familyId.lastIndexOf('::'); + if (separatorIndex === -1) { + return { + modulePath: familyId, + registrationId: '', + }; + } + + return { + modulePath: familyId.slice(0, separatorIndex), + registrationId: familyId.slice(separatorIndex + 2), + }; +}; + +const deriveComponentContent = (componentType: unknown): string => { + if (componentType === undefined) { + return 'undefined'; + } + + if (componentType === null) { + return 'null'; + } + + if (typeof componentType === 'function') { + return String(componentType); + } + + if (typeof componentType === 'object') { + try { + return JSON.stringify(componentType); + } catch { + return String(componentType); + } + } + + return String(componentType); +}; + +const createCheckpointDerivation = ( + internalState: HmrVersioningInternalState, + versionSnapshot: VersionSnapshot, +): CheckpointDerivation => { + const changedModulePathSet = new Set(); + const changedFamilies = versionSnapshot.changedFamilyIds.map( + (changedFamilyId): ChangedFamilyDerivation => { + const familyRegistrationMetadata = + internalState.familyRegistrationMetadataByFamilyId.get(changedFamilyId); + const parsedFamilyId = parseFamilyId(changedFamilyId); + const modulePath = familyRegistrationMetadata?.modulePath ?? parsedFamilyId.modulePath; + if (modulePath) { + changedModulePathSet.add(modulePath); + } + const registrationId = + familyRegistrationMetadata?.registrationId ?? parsedFamilyId.registrationId; + const familyTypeFingerprint = versionSnapshot.familyTypeFingerprintById.get( + changedFamilyId, + ); + const componentType = + versionSnapshot.familyTypeById.get(changedFamilyId) ?? + (familyTypeFingerprint + ? internalState.componentTypeByFingerprint.get(familyTypeFingerprint) + : undefined); + + return { + componentContent: deriveComponentContent(componentType), + familyId: changedFamilyId, + modulePath, + registrationId, + }; + }, + ); + + return { + changedFamilies, + changedModulePaths: Array.from(changedModulePathSet), + timestamp: versionSnapshot.timestamp, + version: versionSnapshot.version, + }; +}; + +const isObjectRecordValue = ( + maybeObjectRecord: unknown, +): maybeObjectRecord is Record => { + return isObjectRecord(maybeObjectRecord); +}; + +const sessionStorageStateKey = '__BIPPY_HMR_VERSIONING_STATE__'; +const persistedStateSchemaVersion = 1; + +const serializeVersionSnapshot = ( + versionSnapshot: VersionSnapshot, +): PersistedVersionSnapshot => { + return { + changedFamilyIds: [...versionSnapshot.changedFamilyIds], + familyTypeFingerprintById: mapToRecord( + versionSnapshot.familyTypeFingerprintById, + ), + timestamp: versionSnapshot.timestamp, + version: versionSnapshot.version, + }; +}; + +const deserializeVersionSnapshot = ( + persistedVersionSnapshot: PersistedVersionSnapshot, +): VersionSnapshot => { + return { + changedFamilyIds: [...persistedVersionSnapshot.changedFamilyIds], + familyTypeFingerprintById: mapFromRecord( + persistedVersionSnapshot.familyTypeFingerprintById, + ), + familyTypeById: new Map(), + timestamp: persistedVersionSnapshot.timestamp, + version: persistedVersionSnapshot.version, + }; +}; + +const loadPersistedState = (): PersistedHmrVersioningState | null => { + try { + const persistedStateString = window.sessionStorage.getItem( + sessionStorageStateKey, + ); + if (!persistedStateString) { + return null; + } + + const persistedStateCandidate: unknown = JSON.parse(persistedStateString); + if (!isObjectRecordValue(persistedStateCandidate)) { + return null; + } + + const schemaVersion = persistedStateCandidate['schemaVersion']; + const timelineSnapshots = persistedStateCandidate['timelineSnapshots']; + const familyFirstSeenVersionById = + persistedStateCandidate['familyFirstSeenVersionById']; + const persistedAtTimestamp = persistedStateCandidate['persistedAtTimestamp']; + + if ( + schemaVersion !== persistedStateSchemaVersion || + !Array.isArray(timelineSnapshots) || + !isObjectRecordValue(familyFirstSeenVersionById) || + typeof persistedAtTimestamp !== 'number' + ) { + return null; + } + + const deserializedTimelineSnapshots: PersistedVersionSnapshot[] = []; + for (const timelineSnapshotCandidate of timelineSnapshots) { + if (!isObjectRecordValue(timelineSnapshotCandidate)) { + return null; + } + const changedFamilyIds = timelineSnapshotCandidate['changedFamilyIds']; + const familyTypeFingerprintById = + timelineSnapshotCandidate['familyTypeFingerprintById']; + const timestamp = timelineSnapshotCandidate['timestamp']; + const version = timelineSnapshotCandidate['version']; + + if ( + !Array.isArray(changedFamilyIds) || + !changedFamilyIds.every( + (changedFamilyId) => typeof changedFamilyId === 'string', + ) || + !isObjectRecordValue(familyTypeFingerprintById) || + typeof timestamp !== 'number' || + typeof version !== 'number' + ) { + return null; + } + + const normalizedFamilyTypeFingerprintById: Record = {}; + for (const [familyId, fingerprintValue] of Object.entries( + familyTypeFingerprintById, + )) { + if (typeof fingerprintValue !== 'string') { + return null; + } + normalizedFamilyTypeFingerprintById[familyId] = fingerprintValue; + } + + deserializedTimelineSnapshots.push({ + changedFamilyIds, + familyTypeFingerprintById: normalizedFamilyTypeFingerprintById, + timestamp, + version, + }); + } + + const normalizedFamilyFirstSeenVersionById: Record = {}; + for (const [familyId, firstSeenVersion] of Object.entries( + familyFirstSeenVersionById, + )) { + if (typeof firstSeenVersion !== 'number') { + return null; + } + normalizedFamilyFirstSeenVersionById[familyId] = firstSeenVersion; + } + + return { + familyFirstSeenVersionById: normalizedFamilyFirstSeenVersionById, + persistedAtTimestamp, + timelineSnapshots: deserializedTimelineSnapshots, + }; + } catch { + return null; + } +}; + +const persistInternalState = (internalState: HmrVersioningInternalState): void => { + try { + const existingPersistedState = loadPersistedState(); + const hasPersistedTimelineEntries = + (existingPersistedState?.timelineSnapshots.length ?? 0) > 0; + if (hasPersistedTimelineEntries) { + return; + } + + const persistedState: PersistedHmrVersioningState = { + familyFirstSeenVersionById: mapToRecord( + internalState.familyFirstSeenVersionById, + ), + persistedAtTimestamp: Date.now(), + timelineSnapshots: internalState.timelineSnapshots.map( + serializeVersionSnapshot, + ), + }; + window.sessionStorage.setItem( + sessionStorageStateKey, + JSON.stringify({ + ...persistedState, + schemaVersion: persistedStateSchemaVersion, + }), + ); + } catch { + return; + } +}; + +const getOrCreateInternalState = (): HmrVersioningInternalState => { + const existingInternalState = window.__BIPPY_HMR_VERSIONING_INTERNAL_STATE__; + if (existingInternalState) { + return existingInternalState; + } + + const persistedState = loadPersistedState(); + const persistedTimelineSnapshots = + persistedState?.timelineSnapshots.map(deserializeVersionSnapshot) ?? []; + const timelineSnapshots = + persistedTimelineSnapshots.length > 0 + ? persistedTimelineSnapshots + : [createInitialVersionSnapshot()]; + const currentVersionIndex = timelineSnapshots.length - 1; + + const nextInternalState: HmrVersioningInternalState = { + activeRefreshModulePath: null, + componentTypeByFingerprint: new Map(), + currentVersionIndex, + familyFirstSeenVersionById: persistedState + ? mapFromRecord(persistedState.familyFirstSeenVersionById) + : new Map(), + familyRegistrationMetadataByFamilyId: new Map< + string, + FamilyRegistrationMetadata + >(), + isApplyingVersion: false, + isBeforePerformHookInstalled: false, + isNextScheduleHookInstalled: false, + isRefreshInterceptTrackingInstalled: false, + isRefreshRegTrackingInstalled: false, + moduleFallbackCounter: 0, + pendingFamilyChangeById: new Map(), + refreshRegistrationHandlerByModulePath: new Map< + string, + (componentType: unknown, registrationId: string) => void + >(), + timelineSnapshots, + }; + window.__BIPPY_HMR_VERSIONING_INTERNAL_STATE__ = nextInternalState; + persistInternalState(nextInternalState); + return nextInternalState; +}; + +const prototypeModulePath = '/src/hmr-versioning-prototype.ts'; +const modulePathExtractOptions = { + ignoredModulePaths: [prototypeModulePath], + modulePathPredicate: (normalizedModulePath: string) => { + if (normalizedModulePath.endsWith('/hmr-versioning-prototype.ts')) { + return false; + } + return ( + normalizedModulePath.startsWith('/src/') || + normalizedModulePath.startsWith('/app/') || + normalizedModulePath.startsWith('/components/') || + normalizedModulePath.includes('/packages/next-playground/') + ); + }, +}; + +const backfillFamilyTypeInHistory = ( + internalState: HmrVersioningInternalState, + familyId: string, + previousType: unknown, +): void => { + if (previousType === undefined) { + return; + } + + const firstSeenVersionIndex = + internalState.familyFirstSeenVersionById.get(familyId) ?? 0; + const latestBackfillVersionIndex = Math.min( + internalState.currentVersionIndex, + internalState.timelineSnapshots.length - 1, + ); + + for ( + let versionIndex = firstSeenVersionIndex; + versionIndex <= latestBackfillVersionIndex; + versionIndex += 1 + ) { + const timelineSnapshot = internalState.timelineSnapshots[versionIndex]; + if (!timelineSnapshot.familyTypeById.has(familyId)) { + timelineSnapshot.familyTypeById.set(familyId, previousType); + } + } +}; + +const backfillFamilyTypeFingerprintInHistory = ( + internalState: HmrVersioningInternalState, + familyId: string, + previousTypeFingerprint: string | undefined, +): void => { + if (!previousTypeFingerprint) { + return; + } + + const firstSeenVersionIndex = + internalState.familyFirstSeenVersionById.get(familyId) ?? 0; + const latestBackfillVersionIndex = Math.min( + internalState.currentVersionIndex, + internalState.timelineSnapshots.length - 1, + ); + + for ( + let versionIndex = firstSeenVersionIndex; + versionIndex <= latestBackfillVersionIndex; + versionIndex += 1 + ) { + const timelineSnapshot = internalState.timelineSnapshots[versionIndex]; + if (!timelineSnapshot.familyTypeFingerprintById.has(familyId)) { + timelineSnapshot.familyTypeFingerprintById.set( + familyId, + previousTypeFingerprint, + ); + } + } +}; + +const hydrateFamilyTypeFromFingerprint = ( + internalState: HmrVersioningInternalState, + familyId: string, + componentTypeFingerprint: string, + componentType: unknown, +): void => { + for (const timelineSnapshot of internalState.timelineSnapshots) { + const timelineFingerprint = timelineSnapshot.familyTypeFingerprintById.get( + familyId, + ); + if (timelineFingerprint !== componentTypeFingerprint) { + continue; + } + + if (!timelineSnapshot.familyTypeById.has(familyId)) { + timelineSnapshot.familyTypeById.set(familyId, componentType); + } + } +}; + +const finalizePendingRefreshChanges = ( + internalState: HmrVersioningInternalState, +): void => { + if (internalState.pendingFamilyChangeById.size === 0) { + return; + } + + const currentVersionIndex = internalState.currentVersionIndex; + if (currentVersionIndex < internalState.timelineSnapshots.length - 1) { + internalState.timelineSnapshots.splice(currentVersionIndex + 1); + } + + const currentVersionSnapshot = + internalState.timelineSnapshots[internalState.currentVersionIndex]; + const nextFamilyTypeById = new Map( + currentVersionSnapshot.familyTypeById, + ); + const nextFamilyTypeFingerprintById = new Map( + currentVersionSnapshot.familyTypeFingerprintById, + ); + const changedFamilyIds: string[] = []; + + for (const pendingFamilyChange of internalState.pendingFamilyChangeById.values()) { + backfillFamilyTypeInHistory( + internalState, + pendingFamilyChange.familyId, + pendingFamilyChange.previousType, + ); + backfillFamilyTypeFingerprintInHistory( + internalState, + pendingFamilyChange.familyId, + pendingFamilyChange.previousTypeFingerprint, + ); + nextFamilyTypeById.set( + pendingFamilyChange.familyId, + pendingFamilyChange.nextType, + ); + nextFamilyTypeFingerprintById.set( + pendingFamilyChange.familyId, + pendingFamilyChange.nextTypeFingerprint, + ); + changedFamilyIds.push(pendingFamilyChange.familyId); + } + + internalState.pendingFamilyChangeById.clear(); + if (changedFamilyIds.length === 0) { + return; + } + + const nextVersionSnapshot: VersionSnapshot = { + changedFamilyIds, + familyTypeFingerprintById: nextFamilyTypeFingerprintById, + familyTypeById: nextFamilyTypeById, + timestamp: Date.now(), + version: internalState.timelineSnapshots.length, + }; + internalState.timelineSnapshots.push(nextVersionSnapshot); + internalState.currentVersionIndex = internalState.timelineSnapshots.length - 1; + persistInternalState(internalState); +}; + +const trackRefreshRegistration = ( + internalState: HmrVersioningInternalState, + modulePath: string, + registrationId: string, + componentType: unknown, +): void => { + if (internalState.isApplyingVersion) { + return; + } + + const familyId = createFamilyId(modulePath, registrationId); + const componentTypeFingerprint = getComponentTypeFingerprint( + familyId, + componentType, + ); + internalState.componentTypeByFingerprint.set( + componentTypeFingerprint, + componentType, + ); + if (!internalState.familyFirstSeenVersionById.has(familyId)) { + internalState.familyFirstSeenVersionById.set( + familyId, + internalState.currentVersionIndex, + ); + } + + internalState.familyRegistrationMetadataByFamilyId.set(familyId, { + familyId, + modulePath, + registrationId, + }); + + const currentVersionSnapshot = + internalState.timelineSnapshots[internalState.currentVersionIndex]; + const previousType = currentVersionSnapshot.familyTypeById.get(familyId); + const previousTypeFingerprint = currentVersionSnapshot.familyTypeFingerprintById.get( + familyId, + ); + currentVersionSnapshot.familyTypeFingerprintById.set( + familyId, + componentTypeFingerprint, + ); + hydrateFamilyTypeFromFingerprint( + internalState, + familyId, + componentTypeFingerprint, + componentType, + ); + if (previousType === undefined) { + currentVersionSnapshot.familyTypeById.set(familyId, componentType); + persistInternalState(internalState); + return; + } + + if (previousType === componentType) { + persistInternalState(internalState); + return; + } + + internalState.pendingFamilyChangeById.set(familyId, { + familyId, + nextTypeFingerprint: componentTypeFingerprint, + nextType: componentType, + previousTypeFingerprint, + previousType, + }); + persistInternalState(internalState); +}; + +const installRefreshInterceptTracking = ( + internalState: HmrVersioningInternalState, +): void => { + if (internalState.isRefreshInterceptTrackingInstalled) { + return; + } + + let currentRefreshInterceptValue = window.$RefreshInterceptModuleExecution$; + const wrappedRefreshInterceptByFunction = new WeakMap< + object, + (modulePath: unknown) => unknown + >(); + + const existingRefreshInterceptDescriptor = Object.getOwnPropertyDescriptor( + window, + '$RefreshInterceptModuleExecution$', + ); + if ( + existingRefreshInterceptDescriptor && + !existingRefreshInterceptDescriptor.configurable + ) { + return; + } + + Object.defineProperty(window, '$RefreshInterceptModuleExecution$', { + configurable: true, + get: () => { + return currentRefreshInterceptValue; + }, + set: (nextRefreshInterceptValue: unknown) => { + if (typeof nextRefreshInterceptValue !== 'function') { + currentRefreshInterceptValue = nextRefreshInterceptValue; + return; + } + + const existingWrappedRefreshIntercept = + wrappedRefreshInterceptByFunction.get(nextRefreshInterceptValue); + if (existingWrappedRefreshIntercept) { + currentRefreshInterceptValue = existingWrappedRefreshIntercept; + return; + } + + const wrappedRefreshInterceptModuleExecution = ( + modulePathCandidate: unknown, + ) => { + const previousActiveRefreshModulePath = internalState.activeRefreshModulePath; + const normalizedRefreshModulePath = normalizeRefreshModulePath( + modulePathCandidate, + ); + if (normalizedRefreshModulePath) { + internalState.activeRefreshModulePath = normalizedRefreshModulePath; + } + + const cleanupRefreshIntercept = nextRefreshInterceptValue( + modulePathCandidate, + ); + + return () => { + if (typeof cleanupRefreshIntercept === 'function') { + cleanupRefreshIntercept(); + } + internalState.activeRefreshModulePath = previousActiveRefreshModulePath; + }; + }; + + wrappedRefreshInterceptByFunction.set( + nextRefreshInterceptValue, + wrappedRefreshInterceptModuleExecution, + ); + wrappedRefreshInterceptByFunction.set( + wrappedRefreshInterceptModuleExecution, + wrappedRefreshInterceptModuleExecution, + ); + currentRefreshInterceptValue = wrappedRefreshInterceptModuleExecution; + }, + }); + + if (typeof currentRefreshInterceptValue === 'function') { + window.$RefreshInterceptModuleExecution$ = currentRefreshInterceptValue; + } + + internalState.isRefreshInterceptTrackingInstalled = true; +}; + +const installRefreshRegTracking = ( + internalState: HmrVersioningInternalState, +): void => { + if (internalState.isRefreshRegTrackingInstalled) { + return; + } + + let currentRefreshRegValue = window.$RefreshReg$; + const wrappedModulePathByRefreshRegistrationHandler = new WeakMap< + object, + string + >(); + + const existingRefreshRegDescriptor = Object.getOwnPropertyDescriptor( + window, + '$RefreshReg$', + ); + if (existingRefreshRegDescriptor && !existingRefreshRegDescriptor.configurable) { + return; + } + + Object.defineProperty(window, '$RefreshReg$', { + configurable: true, + get: () => { + return currentRefreshRegValue; + }, + set: (nextRefreshRegValue: unknown) => { + if (typeof nextRefreshRegValue !== 'function') { + currentRefreshRegValue = nextRefreshRegValue; + return; + } + + if ( + wrappedModulePathByRefreshRegistrationHandler.has(nextRefreshRegValue) + ) { + currentRefreshRegValue = nextRefreshRegValue; + return; + } + + const wrappedRefreshRegistrationHandler = ( + componentType: unknown, + registrationId: string, + ) => { + nextRefreshRegValue(componentType, registrationId); + + const modulePathFromStack = extractModulePathFromStack( + new Error().stack, + modulePathExtractOptions, + ); + const modulePathFromRefreshIntercept = internalState.activeRefreshModulePath; + if (!modulePathFromRefreshIntercept && !modulePathFromStack) { + internalState.moduleFallbackCounter += 1; + } + const modulePath = + modulePathFromRefreshIntercept ?? + modulePathFromStack ?? + `__module_${internalState.moduleFallbackCounter}`; + + internalState.refreshRegistrationHandlerByModulePath.set( + modulePath, + wrappedRefreshRegistrationHandler, + ); + trackRefreshRegistration( + internalState, + modulePath, + registrationId, + componentType, + ); + }; + wrappedModulePathByRefreshRegistrationHandler.set( + wrappedRefreshRegistrationHandler, + prototypeModulePath, + ); + currentRefreshRegValue = wrappedRefreshRegistrationHandler; + }, + }); + + if (typeof currentRefreshRegValue === 'function') { + window.$RefreshReg$ = currentRefreshRegValue; + } + + internalState.isRefreshRegTrackingInstalled = true; +}; + +const installBeforePerformRefreshHook = ( + internalState: HmrVersioningInternalState, +): void => { + if (internalState.isBeforePerformHookInstalled) { + return; + } + + const registerBeforePerformHook = window.__registerBeforePerformReactRefresh; + if (typeof registerBeforePerformHook !== 'function') { + return; + } + + registerBeforePerformHook(() => { + if (!internalState.isApplyingVersion) { + finalizePendingRefreshChanges(internalState); + } + }); + internalState.isBeforePerformHookInstalled = true; +}; + +const installNextScheduleHook = ( + internalState: HmrVersioningInternalState, + nextRefreshHelpers: NextRefreshHelpers, +): void => { + if (internalState.isNextScheduleHookInstalled) { + return; + } + + const originalScheduleUpdate = nextRefreshHelpers.scheduleUpdate; + nextRefreshHelpers.scheduleUpdate = () => { + if (!internalState.isApplyingVersion) { + finalizePendingRefreshChanges(internalState); + } + originalScheduleUpdate(); + }; + internalState.isNextScheduleHookInstalled = true; +}; + +const refreshBoundaryModuleExports = { + RefreshTriggerComponent: () => null, +}; + +const triggerRefreshUpdate = ( + refreshRuntime: RefreshRuntime | null, + nextRefreshHelpers: NextRefreshHelpers | null, +): boolean => { + if (refreshRuntime) { + refreshRuntime.validateRefreshBoundaryAndEnqueueUpdate( + '__bippy_hmr_versioning__', + refreshBoundaryModuleExports, + refreshBoundaryModuleExports, + ); + return true; + } + + if (nextRefreshHelpers) { + nextRefreshHelpers.scheduleUpdate(); + return true; + } + + return false; +}; + +const registerFamilyTypeForVersionApply = ( + refreshRuntime: RefreshRuntime | null, + internalState: HmrVersioningInternalState, + familyId: string, + componentType: unknown, +): boolean => { + const familyRegistrationMetadata = + internalState.familyRegistrationMetadataByFamilyId.get(familyId); + if (!familyRegistrationMetadata) { + return false; + } + + const refreshRegistrationHandlerFromHistory = + internalState.refreshRegistrationHandlerByModulePath.get( + familyRegistrationMetadata.modulePath, + ); + const refreshRegistrationHandler = + refreshRegistrationHandlerFromHistory ?? + (refreshRuntime + ? refreshRuntime.getRefreshReg(familyRegistrationMetadata.modulePath) + : null); + if (!refreshRegistrationHandler) { + return false; + } + + const registrationType = (() => { + if (typeof componentType !== 'function') { + return componentType; + } + + const maybeComponentPrototype = isObjectRecordValue(componentType.prototype) + ? componentType.prototype + : null; + const isClassComponent = Boolean( + maybeComponentPrototype && 'isReactComponent' in maybeComponentPrototype, + ); + if (isClassComponent) { + return componentType; + } + + const wrappedComponentType = (receivedProps: unknown) => { + return Reflect.apply(componentType, undefined, [receivedProps]); + }; + return wrappedComponentType; + })(); + + refreshRegistrationHandler( + registrationType, + familyRegistrationMetadata.registrationId, + ); + return true; +}; + +const createHmrVersioningController = ( + refreshRuntime: RefreshRuntime | null, + nextRefreshHelpers: NextRefreshHelpers | null, + internalState: HmrVersioningInternalState, +): HmrVersioningController => { + const applyVersionAtIndex = (targetVersionIndex: number): boolean => { + if ( + targetVersionIndex < 0 || + targetVersionIndex >= internalState.timelineSnapshots.length + ) { + return false; + } + + if (targetVersionIndex === internalState.currentVersionIndex) { + return true; + } + + const targetVersionSnapshot = + internalState.timelineSnapshots[targetVersionIndex]; + const targetFamilyIds = Array.from( + targetVersionSnapshot.familyTypeFingerprintById.keys(), + ); + + let didQueueRefreshUpdate = false; + let didSkipAnyFamily = false; + internalState.isApplyingVersion = true; + + try { + for (const familyId of targetFamilyIds) { + const targetTypeFingerprint = + targetVersionSnapshot.familyTypeFingerprintById.get(familyId); + if (!targetTypeFingerprint) { + didSkipAnyFamily = true; + continue; + } + + const targetType = + targetVersionSnapshot.familyTypeById.get(familyId) ?? + internalState.componentTypeByFingerprint.get(targetTypeFingerprint); + if (targetType === undefined) { + didSkipAnyFamily = true; + continue; + } + + const didRegisterFamilyType = registerFamilyTypeForVersionApply( + refreshRuntime, + internalState, + familyId, + targetType, + ); + if (didRegisterFamilyType) { + didQueueRefreshUpdate = true; + } + } + + if (didQueueRefreshUpdate) { + const didTriggerRefreshUpdate = triggerRefreshUpdate( + refreshRuntime, + nextRefreshHelpers, + ); + if (!didTriggerRefreshUpdate) { + return false; + } + } + if (!didQueueRefreshUpdate && didSkipAnyFamily) { + return false; + } + internalState.currentVersionIndex = targetVersionIndex; + persistInternalState(internalState); + return true; + } finally { + internalState.isApplyingVersion = false; + } + }; + + return { + getCheckpointDerivations: () => { + return internalState.timelineSnapshots.map((timelineSnapshot) => + createCheckpointDerivation(internalState, timelineSnapshot), + ); + }, + getCurrentVersion: () => { + return createVersionSummary( + internalState.timelineSnapshots[internalState.currentVersionIndex], + ); + }, + getTimeline: () => { + return internalState.timelineSnapshots.map(createVersionSummary); + }, + jumpToVersion: (targetVersionIndex: number) => { + return applyVersionAtIndex(targetVersionIndex); + }, + redo: () => { + return applyVersionAtIndex(internalState.currentVersionIndex + 1); + }, + undo: () => { + return applyVersionAtIndex(internalState.currentVersionIndex - 1); + }, + }; +}; + +const initializeHmrVersioningPrototype = async (): Promise => { + if (typeof window === 'undefined') { + return; + } + + const isViteHotEnvironment = + typeof window.$RefreshReg$ === 'function' || + typeof window.__registerBeforePerformReactRefresh === 'function'; + const isHotEnvironment = + isViteHotEnvironment || + typeof window.$RefreshInterceptModuleExecution$ === 'function' || + isObjectRecordValue(window.$RefreshHelpers$); + if (!isHotEnvironment) { + return; + } + + const internalState = getOrCreateInternalState(); + installRefreshInterceptTracking(internalState); + installRefreshRegTracking(internalState); + installBeforePerformRefreshHook(internalState); + + if (window.__BIPPY_HMR_VERSIONING__) { + return; + } + + const refreshRuntime = await loadViteRefreshRuntime(); + const nextRefreshHelpers = getNextRefreshHelpers(); + if (!refreshRuntime && !nextRefreshHelpers) { + return; + } + + installBeforePerformRefreshHook(internalState); + if (nextRefreshHelpers) { + installNextScheduleHook(internalState, nextRefreshHelpers); + } + + const hmrVersioningController = createHmrVersioningController( + refreshRuntime, + nextRefreshHelpers, + internalState, + ); + + window.__BIPPY_HMR_VERSIONING__ = hmrVersioningController; +}; + +if (typeof window !== 'undefined') { + void initializeHmrVersioningPrototype(); +} diff --git a/packages/vite-playground/src/main.tsx b/packages/vite-playground/src/main.tsx index 0f3b5b98..b81de2a5 100644 --- a/packages/vite-playground/src/main.tsx +++ b/packages/vite-playground/src/main.tsx @@ -1,4 +1,5 @@ import 'bippy/install-hook-only'; +import './hmr-versioning-prototype'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; diff --git a/packages/vite-playground/src/vite-react-refresh.d.ts b/packages/vite-playground/src/vite-react-refresh.d.ts new file mode 100644 index 00000000..53c0ca79 --- /dev/null +++ b/packages/vite-playground/src/vite-react-refresh.d.ts @@ -0,0 +1,25 @@ +declare module '/@react-refresh' { + interface ViteReactRefreshDefaultExport { + injectIntoGlobalHook: (globalObject: unknown) => void; + } + + export const __hmr_import: (module: string) => Promise; + export const createSignatureFunctionForTransform: () => ( + componentType: unknown, + ) => unknown; + export const getRefreshReg: ( + filename: string, + ) => (componentType: unknown, registrationId: string) => void; + export const registerExportsForReactRefresh: ( + filename: string, + moduleExports: Record, + ) => void; + export const validateRefreshBoundaryAndEnqueueUpdate: ( + moduleId: string, + previousExports: Record, + nextExports: Record, + ) => string | undefined; + const viteReactRefreshDefaultExport: ViteReactRefreshDefaultExport; + + export default viteReactRefreshDefaultExport; +}