Skip to content

Commit

Permalink
make HMR re-execute modules that self invalidate (vercel/turborepo#5161)
Browse files Browse the repository at this point in the history
### Description
This can happen when a module that previously exported a React component
no longer does, the next react refresh integration will then call
`module.hot.invalidate()`

fixes the `acceptance/ReactRefreshRequire.test.ts` next.js integration
test
  • Loading branch information
ForsakenHarmony authored Jun 1, 2023
1 parent 8b15c16 commit b1f24bf
Show file tree
Hide file tree
Showing 3 changed files with 119 additions and 56 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

// This file must not use `import` and `export` statements. Otherwise, it
// becomes impossible to augment interfaces declared in `<reference>`d files
// (e.g. `Module`). Hence the need for `import()` here.
// (e.g. `Module`). Hence, the need for `import()` here.
type RefreshRuntimeGlobals =
import("@next/react-refresh-utils/dist/runtime").RefreshRuntimeGlobals;

Expand All @@ -30,20 +30,7 @@ type RefreshContext = {
signature: RefreshRuntimeGlobals["$RefreshSig$"];
};

// @next/react-refresh-utils/internal/helpers
type RefreshHelpers = {
registerExportsForReactRefresh(
moduleExports: unknown,
moduleID: string
): void;
getRefreshBoundarySignature(moduleExports: unknown): Array<unknown>;
isReactRefreshBoundary(moduleExports: unknown): boolean;
shouldInvalidateReactRefreshBoundary(
prevExports: unknown,
nextExports: unknown
): boolean;
scheduleUpdate(): void;
};
type RefreshHelpers = RefreshRuntimeGlobals["$RefreshHelpers$"];

interface TurbopackDevBaseContext {
e: Module["exports"];
Expand Down Expand Up @@ -139,6 +126,10 @@ const moduleHotData: Map<ModuleId, HotData> = new Map();
* Maps module instances to their hot module state.
*/
const moduleHotState: Map<Module, HotState> = new Map();
/**
* Modules that call `module.hot.invalidate()` (while being updated).
*/
const queuedInvalidatedModules: Set<ModuleId> = new Set();
/**
* Module IDs that are instantiated as part of the runtime of a chunk.
*/
Expand All @@ -152,7 +143,7 @@ const runtimeModules: Set<ModuleId> = new Set();
*/
const moduleChunksMap: Map<ModuleId, Set<ChunkPath>> = new Map();
/**
* Map from chunk path to all modules it contains.
* Map from a chunk path to all modules it contains.
*/
const chunkModulesMap: Map<ModuleId, Set<ChunkPath>> = new Map();
/**
Expand All @@ -162,11 +153,11 @@ const chunkModulesMap: Map<ModuleId, Set<ChunkPath>> = new Map();
*/
const runtimeChunkLists: Set<ChunkPath> = new Set();
/**
* Map from chunk list to the chunk paths it contains.
* Map from a chunk list to the chunk paths it contains.
*/
const chunkListChunksMap: Map<ChunkPath, Set<ChunkPath>> = new Map();
/**
* Map from chunk path to the chunk lists it belongs to.
* Map from a chunk path to the chunk lists it belongs to.
*/
const chunkChunkListsMap: Map<ChunkPath, Set<ChunkPath>> = new Map();

Expand Down Expand Up @@ -299,7 +290,7 @@ function instantiateModule(id: ModuleId, source: SourceInfo): Module {
}

const hotData = moduleHotData.get(id)!;
const { hot, hotState } = createModuleHot(hotData);
const { hot, hotState } = createModuleHot(id, hotData);

let parents: ModuleId[];
switch (source.type) {
Expand Down Expand Up @@ -349,7 +340,7 @@ function instantiateModule(id: ModuleId, source: SourceInfo): Module {
l: loadChunk.bind(null, { type: SourceType.Parent, parentId: id }),
g: globalThis,
k: refresh,
__dirname: module.id.replace(/(^|\/)[\/]+$/, ""),
__dirname: module.id.replace(/(^|\/)\/+$/, ""),
})
);
});
Expand All @@ -368,7 +359,7 @@ function instantiateModule(id: ModuleId, source: SourceInfo): Module {
}

/**
* NOTE(alexkirsz) Webpack has an "module execution" interception hook that
* NOTE(alexkirsz) Webpack has a "module execution" interception hook that
* Next.js' React Refresh runtime hooks into to add module context to the
* refresh registry.
*/
Expand Down Expand Up @@ -451,7 +442,7 @@ function registerExportsAndSetupBoundaryForReactRefresh(
// A module can be accepted automatically based on its exports, e.g. when
// it is a Refresh Boundary.
if (helpers.isReactRefreshBoundary(currentExports)) {
// Save the previous exports on update so we can compare the boundary
// Save the previous exports on update, so we can compare the boundary
// signatures.
module.hot.dispose((data) => {
data.prevExports = currentExports;
Expand Down Expand Up @@ -501,17 +492,33 @@ function formatDependencyChain(dependencyChain: ModuleId[]): string {
function computeOutdatedModules(
added: Map<ModuleId, EcmascriptModuleEntry | undefined>,
modified: Map<ModuleId, EcmascriptModuleEntry>
): { outdatedModules: Set<any>; newModuleFactories: Map<any, any> } {
const outdatedModules = new Set();
const newModuleFactories = new Map();
): {
outdatedModules: Set<ModuleId>;
newModuleFactories: Map<ModuleId, ModuleFactory>;
} {
const newModuleFactories = new Map<ModuleId, ModuleFactory>();

for (const [moduleId, entry] of added) {
if (entry != null) {
newModuleFactories.set(moduleId, _eval(entry));
}
}

const outdatedModules = computedInvalidatedModules(modified.keys());

for (const [moduleId, entry] of modified) {
newModuleFactories.set(moduleId, _eval(entry));
}

return { outdatedModules, newModuleFactories };
}

function computedInvalidatedModules(
invalidated: Iterable<ModuleId>
): Set<ModuleId> {
const outdatedModules = new Set<ModuleId>();

for (const moduleId of invalidated) {
const effect = getAffectedModuleEffects(moduleId);

switch (effect.type) {
Expand All @@ -528,7 +535,6 @@ function computeOutdatedModules(
)}.`
);
case "accepted":
newModuleFactories.set(moduleId, _eval(entry));
for (const outdatedModuleId of effect.outdatedModules) {
outdatedModules.add(outdatedModuleId);
}
Expand All @@ -537,7 +543,7 @@ function computeOutdatedModules(
}
}

return { outdatedModules, newModuleFactories };
return outdatedModules;
}

function computeOutdatedSelfAcceptedModules(
Expand Down Expand Up @@ -586,7 +592,7 @@ function updateChunksPhase(

function disposePhase(
outdatedModules: Iterable<ModuleId>,
disposedModules: Set<ModuleId>
disposedModules: Iterable<ModuleId>
): { outdatedModuleParents: Map<ModuleId, Array<ModuleId>> } {
for (const moduleId of outdatedModules) {
disposeModule(moduleId, "replace");
Expand Down Expand Up @@ -619,9 +625,9 @@ function disposePhase(
*
* NOTE: mode = "replace" will not remove modules from the moduleCache.
* This must be done in a separate step afterwards.
* This is important because all modules need to be diposed to update the
* This is important because all modules need to be disposed to update the
* parent/child relationships before they are actually removed from the moduleCache.
* If this would be done in this method, following disposeModulecalls won't find
* If this was done in this method, the following disposeModule calls won't find
* the module from the module id in the cache.
*/
function disposeModule(moduleId: ModuleId, mode: "clear" | "replace") {
Expand All @@ -647,7 +653,7 @@ function disposeModule(moduleId: ModuleId, mode: "clear" | "replace") {

// TODO(alexkirsz) Dependencies: delete the module from outdated deps.

// Remove the disposed module from its children's parents list.
// Remove the disposed module from its children's parent list.
// It will be added back once the module re-instantiates and imports its
// children again.
for (const childId of module.children) {
Expand Down Expand Up @@ -777,24 +783,55 @@ function applyEcmascriptMergedUpdate(
update: EcmascriptMergedUpdate
) {
const { entries = {}, chunks = {} } = update;
const { added, modified, deleted, chunksAdded, chunksDeleted } =
computeChangedModules(entries, chunks);
const { added, modified, chunksAdded, chunksDeleted } = computeChangedModules(
entries,
chunks
);
const { outdatedModules, newModuleFactories } = computeOutdatedModules(
added,
modified
);
const { disposedModules } = updateChunksPhase(chunksAdded, chunksDeleted);

applyInternal(outdatedModules, disposedModules, newModuleFactories);
}

function applyInvalidatedModules(outdatedModules: Set<ModuleId>) {
if (queuedInvalidatedModules.size > 0) {
computedInvalidatedModules(queuedInvalidatedModules).forEach((moduleId) => {
outdatedModules.add(moduleId);
});

queuedInvalidatedModules.clear();
}

return outdatedModules;
}

function applyInternal(
outdatedModules: Set<ModuleId>,
disposedModules: Iterable<ModuleId>,
newModuleFactories: Map<ModuleId, ModuleFactory>
) {
outdatedModules = applyInvalidatedModules(outdatedModules);

const outdatedSelfAcceptedModules =
computeOutdatedSelfAcceptedModules(outdatedModules);
const { disposedModules } = updateChunksPhase(chunksAdded, chunksDeleted);

const { outdatedModuleParents } = disposePhase(
outdatedModules,
disposedModules
);

applyPhase(
outdatedSelfAcceptedModules,
newModuleFactories,
outdatedModuleParents
);

if (queuedInvalidatedModules.size > 0) {
applyInternal(new Set(), [], new Map());
}
}

function computeChangedModules(
Expand Down Expand Up @@ -1007,7 +1044,10 @@ function handleApply(chunkListPath: ChunkPath, update: ServerMessage) {
}
}

function createModuleHot(hotData: HotData): { hot: Hot; hotState: HotState } {
function createModuleHot(
moduleId: ModuleId,
hotData: HotData
): { hot: Hot; hotState: HotState } {
const hotState: HotState = {
selfAccepted: false,
selfDeclined: false,
Expand Down Expand Up @@ -1063,8 +1103,7 @@ function createModuleHot(hotData: HotData): { hot: Hot; hotState: HotState } {

invalidate: () => {
hotState.selfInvalidated = true;
// TODO(alexkirsz) The original HMR code had management-related code
// here.
queuedInvalidatedModules.add(moduleId);
},

// NOTE(alexkirsz) This is part of the management API, which we don't
Expand Down Expand Up @@ -1116,8 +1155,8 @@ function getFirstModuleChunk(moduleId: ModuleId) {
}

/**
* Removes a module from a chunk. Returns true there are no remaining chunks
* including this module.
* Removes a module from a chunk.
* Returns `true` if there are no remaining chunks, including this module.
*/
function removeModuleFromChunk(
moduleId: ModuleId,
Expand All @@ -1143,7 +1182,7 @@ function removeModuleFromChunk(
}

/**
* Diposes of a chunk list and its corresponding exclusive chunks.
* Disposes of a chunk list and its corresponding exclusive chunks.
*/
function disposeChunkList(chunkListPath: ChunkPath): boolean {
const chunkPaths = chunkListChunksMap.get(chunkListPath);
Expand Down Expand Up @@ -1175,8 +1214,8 @@ function disposeChunkList(chunkListPath: ChunkPath): boolean {
* @returns Whether the chunk was disposed of.
*/
function disposeChunk(chunkPath: ChunkPath): boolean {
// This should happen whether or not the chunk has any modules in it. For instance,
// CSS chunks have no modules in them, but they still need to be unloaded.
// This should happen whether the chunk has any modules in it or not.
// For instance, CSS chunks have no modules in them, but they still need to be unloaded.
BACKEND.unloadChunk?.(chunkPath);

const chunkModules = chunkModulesMap.get(chunkPath);
Expand Down
Loading

0 comments on commit b1f24bf

Please sign in to comment.