refactor(client): speed up client updates (#65025)

This commit is contained in:
Oliver Eyton-Williams
2026-02-10 13:39:55 +01:00
committed by GitHub
parent d6d452dfac
commit e6eb338fe6
6 changed files with 258 additions and 106 deletions
+1 -87
View File
@@ -8,7 +8,6 @@ const webpack = require('webpack');
const { SuperBlocks } = require('@freecodecamp/shared/config/curriculum'); const { SuperBlocks } = require('@freecodecamp/shared/config/curriculum');
const env = require('./config/env.json'); const env = require('./config/env.json');
const { const {
createChallengePages,
createBlockIntroPages, createBlockIntroPages,
createSuperBlockIntroPages createSuperBlockIntroPages
} = require('./utils/gatsby'); } = require('./utils/gatsby');
@@ -60,62 +59,11 @@ exports.createPages = async function createPages({
const result = await graphql(` const result = await graphql(`
{ {
allChallengeNode( allChallengeNode {
sort: {
fields: [
challenge___superOrder
challenge___order
challenge___challengeOrder
]
}
) {
edges { edges {
node { node {
id
challenge { challenge {
block block
blockLabel
blockLayout
certification
challengeType
dashedName
demoType
disableLoopProtectTests
disableLoopProtectPreview
fields {
slug
blockHashSlug
}
id
isLastChallengeInBlock
order
required {
link
src
}
challengeOrder
challengeFiles {
name
ext
contents
head
tail
history
fileKey
}
saveSubmissionToDB
solutions {
contents
ext
history
fileKey
}
superBlock
superOrder
template
usesMultifileEditor
chapter
module
} }
} }
} }
@@ -140,40 +88,6 @@ exports.createPages = async function createPages({
} }
`); `);
const allChallengeNodes = result.data.allChallengeNode.edges.map(
({ node }) => node
);
const createIdToNextPathMap = nodes =>
nodes.reduce((map, node, index) => {
const nextNode = nodes[index + 1];
const nextPath = nextNode ? nextNode.challenge.fields.slug : null;
if (nextPath) map[node.id] = nextPath;
return map;
}, {});
const createIdToPrevPathMap = nodes =>
nodes.reduce((map, node, index) => {
const prevNode = nodes[index - 1];
const prevPath = prevNode ? prevNode.challenge.fields.slug : null;
if (prevPath) map[node.id] = prevPath;
return map;
}, {});
const idToNextPathCurrentCurriculum =
createIdToNextPathMap(allChallengeNodes);
const idToPrevPathCurrentCurriculum =
createIdToPrevPathMap(allChallengeNodes);
// Create challenge pages.
result.data.allChallengeNode.edges.forEach(
createChallengePages(createPage, {
idToNextPathCurrentCurriculum,
idToPrevPathCurrentCurriculum
})
);
const blocks = uniq( const blocks = uniq(
result.data.allChallengeNode.edges.map( result.data.allChallengeNode.edges.map(
({ ({
-1
View File
@@ -51,7 +51,6 @@ exports.replaceChallengeNodes = () => {
const block = path.basename(parentDir); const block = path.basename(parentDir);
const filename = path.basename(filePath); const filename = path.basename(filePath);
console.log(`Replacing challenge nodes for ${filePath}`);
const meta = getBlockStructure(block); const meta = getBlockStructure(block);
const superblocks = getSuperblocks(block); const superblocks = getSuperblocks(block);
+13 -11
View File
@@ -69,23 +69,25 @@ const views = {
examDownload examDownload
}; };
function getIsFirstStepInBlock(id, edges) { function getIsFirstStepInBlock(id, nodes) {
const current = edges[id]; const current = nodes[id];
const previous = edges[id - 1]; const previous = nodes[id - 1];
if (!previous) return true; if (!previous) return true;
return previous.node.challenge.block !== current.node.challenge.block; return previous.challenge.block !== current.challenge.block;
} }
function getTemplateComponent(challengeType) { function getTemplateComponent(challengeType) {
return views[viewTypes[challengeType]]; return views[viewTypes[challengeType]];
} }
exports.getTemplateComponent = getTemplateComponent;
exports.createChallengePages = function ( exports.createChallengePages = function (
createPage, createPage,
{ idToNextPathCurrentCurriculum, idToPrevPathCurrentCurriculum } { idToNextPathCurrentCurriculum, idToPrevPathCurrentCurriculum }
) { ) {
return function ({ node }, index, allChallengeEdges) { return function (node, index, allChallengeNodes) {
const { const {
dashedName, dashedName,
disableLoopProtectTests, disableLoopProtectTests,
@@ -118,7 +120,7 @@ exports.createChallengePages = function (
chapter, chapter,
module, module,
block, block,
isFirstStep: getIsFirstStepInBlock(index, allChallengeEdges), isFirstStep: getIsFirstStepInBlock(index, allChallengeNodes),
template, template,
required, required,
isLastChallengeInBlock: isLastChallengeInBlock, isLastChallengeInBlock: isLastChallengeInBlock,
@@ -129,7 +131,7 @@ exports.createChallengePages = function (
}, },
projectPreview: getProjectPreviewConfig( projectPreview: getProjectPreviewConfig(
node.challenge, node.challenge,
allChallengeEdges allChallengeNodes
), ),
id: node.id id: node.id
} }
@@ -140,12 +142,12 @@ exports.createChallengePages = function (
// TODO: figure out a cleaner way to get the last challenge in a block. Create // TODO: figure out a cleaner way to get the last challenge in a block. Create
// it during the curriculum build process and attach it to the first challenge? // it during the curriculum build process and attach it to the first challenge?
// That would remove the need to analyse allChallengeEdges. // That would remove the need to analyse allChallengeEdges.
function getProjectPreviewConfig(challenge, allChallengeEdges) { function getProjectPreviewConfig(challenge, allChallengeNodes) {
const { block } = challenge; const { block } = challenge;
const challengesInBlock = allChallengeEdges const challengesInBlock = allChallengeNodes
.filter(({ node: { challenge } }) => challenge.block === block) .filter(({ challenge }) => challenge.block === block)
.map(({ node: { challenge } }) => challenge); .map(({ challenge }) => challenge);
const lastChallenge = challengesInBlock[challengesInBlock.length - 1]; const lastChallenge = challengesInBlock[challengesInBlock.length - 1];
const solutionFiles = lastChallenge.solutions[0] ?? []; const solutionFiles = lastChallenge.solutions[0] ?? [];
const lastChallengeFiles = lastChallenge.challengeFiles ?? []; const lastChallengeFiles = lastChallenge.challengeFiles ?? [];
+2
View File
@@ -342,6 +342,8 @@ const schema = Joi.object().keys({
}) })
}), }),
showSpeakingButton: Joi.bool(), showSpeakingButton: Joi.bool(),
// This is only to be used for dynamic client updates.
sourceLocation: Joi.string(),
solutions: Joi.array().items(Joi.array().items(fileJoi).min(1)), solutions: Joi.array().items(Joi.array().items(fileJoi).min(1)),
superBlock: Joi.string().regex(slugWithSlashRE), superBlock: Joi.string().regex(slugWithSlashRE),
superOrder: Joi.number(), superOrder: Joi.number(),
+8 -1
View File
@@ -1,5 +1,5 @@
import { existsSync, readdirSync } from 'fs'; import { existsSync, readdirSync } from 'fs';
import { resolve } from 'path'; import { join, resolve, basename } from 'path';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import debug from 'debug'; import debug from 'debug';
@@ -336,6 +336,13 @@ export class BlockCreator {
); );
challenge.translationPending = this.lang !== 'english' && !isAudited; challenge.translationPending = this.lang !== 'english' && !isAudited;
// Add source location to allow tracing back to original file (necessary to
// update the client when files change)
challenge.sourceLocation = join(
basename(this.blockContentDir),
block,
filename
);
return finalizeChallenge(challenge, meta); return finalizeChallenge(challenge, meta);
} }
@@ -1,4 +1,5 @@
const chokidar = require('chokidar'); const chokidar = require('chokidar');
const { const {
getSuperblockStructure getSuperblockStructure
} = require('@freecodecamp/curriculum/file-handler'); } = require('@freecodecamp/curriculum/file-handler');
@@ -7,6 +8,23 @@ const {
} = require('@freecodecamp/curriculum/build-curriculum'); } = require('@freecodecamp/curriculum/build-curriculum');
const { createChallengeNode } = require('./create-challenge-nodes'); const { createChallengeNode } = require('./create-challenge-nodes');
const {
createChallengePages,
getTemplateComponent
} = require('../../../client/utils/gatsby');
// createPagesStatefully only runs once, but we need the following when
// updating challenges, so they have to be stored in memory.
let allChallengeNodes;
let idToNextPathCurrentCurriculum;
let idToPrevPathCurrentCurriculum;
const filepathToStatefullyCreatedNodes = new Map();
const filePathToCreatedNodes = new Map();
// reverse lookup, to detect if an updated file has "overwritten" another file
// (i.e. the updated file now has the same node id as another file).
const idToFilepath = new Map();
// recently overwritten files
const idToOverwrittenFile = new Map();
exports.sourceNodes = function sourceChallengesSourceNodes( exports.sourceNodes = function sourceChallengesSourceNodes(
{ actions, reporter, createNodeId, createContentDigest }, { actions, reporter, createNodeId, createContentDigest },
@@ -31,7 +49,7 @@ exports.sourceNodes = function sourceChallengesSourceNodes(
a path to a curriculum directory a path to a curriculum directory
`); `);
} }
const { createNode } = actions; const { createNode, deleteNode, deletePage } = actions;
const watcher = chokidar.watch(curriculumPath, { const watcher = chokidar.watch(curriculumPath, {
ignored: /(^|[/\\])\../, ignored: /(^|[/\\])\../,
ignoreInitial: true, ignoreInitial: true,
@@ -39,16 +57,93 @@ exports.sourceNodes = function sourceChallengesSourceNodes(
cwd: curriculumPath cwd: curriculumPath
}); });
function deletePages(filePath) {
const statefulNodes = filepathToStatefullyCreatedNodes.get(filePath) || [];
statefulNodes.forEach(node => {
deleteNode(node);
deletePage({
path: node.challenge.fields.slug,
component: getTemplateComponent(node.challenge.challengeType)
});
idToFilepath.delete(node.id);
});
const createdNodes = filePathToCreatedNodes.get(filePath) || [];
createdNodes.forEach(node => {
deleteNode(node);
idToFilepath.delete(node.id);
});
filepathToStatefullyCreatedNodes.delete(filePath);
filePathToCreatedNodes.delete(filePath);
}
function tryToDeletePages(filePath) {
const oldCreatedNodeIds = (filePathToCreatedNodes.get(filePath) ?? []).map(
node => node.id
);
const oldStatefullyCreatedNodeIds = (
filepathToStatefullyCreatedNodes.get(filePath) ?? []
).map(node => node.id);
const oldNodeIds = [...oldCreatedNodeIds, ...oldStatefullyCreatedNodeIds];
const overwrittenFiles = new Set(
oldNodeIds.map(id => idToOverwrittenFile.get(id))
);
if (overwrittenFiles.has(filePath)) {
// since this has already been overwritten, it doesn't need
// deleting, but there's no longer any need to track that it was
// overwritten.
oldNodeIds.forEach(id => {
idToOverwrittenFile.delete(id);
});
} else {
deletePages(filePath);
}
}
function handleChallengeUpdate(filePath, action = 'changed') { function handleChallengeUpdate(filePath, action = 'changed') {
if (action === 'deleted') {
// We have to return before calling onSourceChange, since the file is
// gone.
return tryToDeletePages(filePath);
}
return onSourceChange(filePath) return onSourceChange(filePath)
.then(challenges => { .then(challenges => {
const actionText = action === 'added' ? 'creating' : 'replacing'; const actionText = action === 'added' ? 'creating' : 'replacing';
reporter.info( reporter.info(
`Challenge file ${action}: ${filePath}, ${actionText} challengeNodes with ids ${challenges.map(({ id }) => id).join(', ')}` `Challenge file ${action}: ${filePath}, ${actionText} challengeNodes with ids ${challenges.map(({ id }) => id).join(', ')}`
); );
challenges.forEach(challenge =>
createVisibleChallenge(challenge, { isReloading: true }) if (action === 'changed') {
tryToDeletePages(filePath);
}
const challengeNodes = challenges.map(challenge =>
reportNodeCreationToGatsby(challenge, {
isReloading: true
})
); );
// Track if file has been overwritten.
challengeNodes.forEach(({ id }) => {
const maybeFilepath = idToFilepath.get(id);
if (maybeFilepath) {
idToOverwrittenFile.set(id, maybeFilepath);
}
});
challengeNodes.forEach(node => {
idToFilepath.set(node.id, filePath);
});
// we always need to track the created nodes to ensure the pages get
// recreated.
filePathToCreatedNodes.set(filePath, challengeNodes);
}) })
.catch(e => .catch(e =>
reporter.error( reporter.error(
@@ -69,12 +164,27 @@ exports.sourceNodes = function sourceChallengesSourceNodes(
handleChallengeUpdate(filePath, 'added'); handleChallengeUpdate(filePath, 'added');
}); });
watcher.on('unlink', filePath => {
if (!/\.md?$/.test(filePath)) return;
handleChallengeUpdate(filePath, 'deleted');
});
function sourceAndCreateNodes() { function sourceAndCreateNodes() {
return source() return source()
.then(challenges => Promise.all(challenges)) .then(challenges => Promise.all(challenges))
.then(challenges => { .then(challenges => {
// create challenge nodes // create challenge nodes
challenges.forEach(challenge => createVisibleChallenge(challenge)); challenges.forEach(challenge => {
const newNode = reportNodeCreationToGatsby(challenge);
const existingNodes =
filepathToStatefullyCreatedNodes.get(challenge.sourceLocation) ||
[];
filepathToStatefullyCreatedNodes.set(challenge.sourceLocation, [
...existingNodes,
newNode
]);
idToFilepath.set(newNode.id, challenge.sourceLocation);
});
// create superblock structure nodes // create superblock structure nodes
createSuperBlockStructureNodes(); createSuperBlockStructureNodes();
return Promise.resolve(); return Promise.resolve();
@@ -89,8 +199,11 @@ exports.sourceNodes = function sourceChallengesSourceNodes(
}); });
} }
function createVisibleChallenge(challenge, options) { function reportNodeCreationToGatsby(challenge, options) {
createNode(createChallengeNode(challenge, reporter, options)); const challengeNode = createChallengeNode(challenge, reporter, options);
createNode(challengeNode);
return challengeNode;
} }
function createSuperBlockStructureNodes() { function createSuperBlockStructureNodes() {
@@ -126,3 +239,118 @@ exports.sourceNodes = function sourceChallengesSourceNodes(
watcher.on('ready', () => sourceAndCreateNodes().then(resolve, reject)); watcher.on('ready', () => sourceAndCreateNodes().then(resolve, reject));
}); });
}; };
exports.createPagesStatefully = async function ({ graphql, actions }) {
const result = await graphql(`
{
allChallengeNode(
sort: {
fields: [
challenge___superOrder
challenge___order
challenge___challengeOrder
]
}
) {
edges {
node {
id
challenge {
block
blockLabel
blockLayout
certification
challengeType
dashedName
demoType
disableLoopProtectTests
disableLoopProtectPreview
fields {
slug
blockHashSlug
}
id
isLastChallengeInBlock
order
required {
link
src
}
challengeOrder
challengeFiles {
name
ext
contents
head
tail
history
fileKey
}
saveSubmissionToDB
solutions {
contents
ext
history
fileKey
}
superBlock
superOrder
template
usesMultifileEditor
chapter
module
}
}
}
}
}
`);
allChallengeNodes = result.data.allChallengeNode.edges.map(
({ node }) => node
);
const createIdToNextPathMap = nodes =>
nodes.reduce((map, node, index) => {
const nextNode = nodes[index + 1];
const nextPath = nextNode ? nextNode.challenge.fields.slug : null;
if (nextPath) map[node.id] = nextPath;
return map;
}, {});
const createIdToPrevPathMap = nodes =>
nodes.reduce((map, node, index) => {
const prevNode = nodes[index - 1];
const prevPath = prevNode ? prevNode.challenge.fields.slug : null;
if (prevPath) map[node.id] = prevPath;
return map;
}, {});
idToNextPathCurrentCurriculum = createIdToNextPathMap(allChallengeNodes);
idToPrevPathCurrentCurriculum = createIdToPrevPathMap(allChallengeNodes);
const nodeToPage = createChallengePages(actions.createPage, {
idToNextPathCurrentCurriculum,
idToPrevPathCurrentCurriculum
});
// Create challenge pages.
allChallengeNodes.forEach(nodeToPage);
};
exports.createPages = function ({ actions }) {
// actions.createPage has to be called in the createPages hook
const nodes = [...filePathToCreatedNodes.values()].flat();
for (const node of nodes) {
const nodeToPage = createChallengePages(actions.createPage, {
idToNextPathCurrentCurriculum,
idToPrevPathCurrentCurriculum
});
nodeToPage(node, 0, allChallengeNodes);
}
// It's important NOT to clear the createdNodes, since Gatsby deletes any
// pages that are not recreated each time createPages is called.
};