diff --git a/packages/gatsby/src/bootstrap/page-hot-reloader.ts b/packages/gatsby/src/bootstrap/page-hot-reloader.ts index 2465e6e99811c..2f4711277a2d0 100644 --- a/packages/gatsby/src/bootstrap/page-hot-reloader.ts +++ b/packages/gatsby/src/bootstrap/page-hot-reloader.ts @@ -1,7 +1,7 @@ import { emitter, store } from "../redux" import apiRunnerNode from "../utils/api-runner-node" import { boundActionCreators } from "../redux/actions" -const { deletePage, deleteComponentsDependencies } = boundActionCreators +const { deletePage } = boundActionCreators import report from "gatsby-cli/lib/reporter" import { ICreateNodeAction, @@ -39,7 +39,6 @@ const runCreatePages = async (): Promise => { page.updatedAt < timestamp && page.path !== `/404.html` ) { - deleteComponentsDependencies([page.path]) deletePage(page) } }) diff --git a/packages/gatsby/src/query/__tests__/data-tracking.js b/packages/gatsby/src/query/__tests__/data-tracking.js index a621539a43c45..aea550421ba77 100644 --- a/packages/gatsby/src/query/__tests__/data-tracking.js +++ b/packages/gatsby/src/query/__tests__/data-tracking.js @@ -200,14 +200,18 @@ const setup = async ({ restart = isFirstRun, clearCache = false } = {}) => { }) Object.entries(staticQueries).forEach(([id, query]) => { - store.dispatch({ - type: `REPLACE_STATIC_QUERY`, - payload: { - id: `sq--${id}`, - hash: `sq--${id}`, - query, - }, - }) + // Mimic real code behavior by only calling this action when static query text changes + const lastQuery = mockPersistedState.staticQueryComponents?.get(`sq--${id}`) + if (lastQuery?.query !== query) { + store.dispatch({ + type: `REPLACE_STATIC_QUERY`, + payload: { + id: `sq--${id}`, + hash: `sq--${id}`, + query, + }, + }) + } }) const queryIds = queryUtil.calcInitialDirtyQueryIds(store.getState()) @@ -1238,9 +1242,7 @@ describe(`query caching between builds`, () => { expect(staticQueriesThatRan).toEqual([]) }, 999999) - // TO-DO: this is known issue - we always rerun queries for pages with no dependencies - // this mean that we will retry to rerun them every time we restart gatsby - it.skip(`rerunning should not run any queries (with restart)`, async () => { + it(`rerunning should not run any queries (with restart)`, async () => { const { pathsOfPagesWithQueriesThatRan, staticQueriesThatRan, diff --git a/packages/gatsby/src/query/index.js b/packages/gatsby/src/query/index.js index 2a699896d29c7..6fe72f04e6367 100644 --- a/packages/gatsby/src/query/index.js +++ b/packages/gatsby/src/query/index.js @@ -1,152 +1,36 @@ -// @flow - const _ = require(`lodash`) -const Queue = require(`better-queue`) -// const convertHrtime = require(`convert-hrtime`) -const { store, emitter } = require(`../redux`) -const { boundActionCreators } = require(`../redux/actions`) -const report = require(`gatsby-cli/lib/reporter`) +const { store } = require(`../redux`) +const { hasFlag, FLAG_ERROR_EXTRACTION } = require(`../redux/reducers/queries`) const queryQueue = require(`./queue`) -const { GraphQLRunner } = require(`./graphql-runner`) -const pageDataUtil = require(`../utils/page-data`) - -const seenIdsWithoutDataDependencies = new Set() -let queuedDirtyActions = [] -const extractedQueryIds = new Set() - -// Remove pages from seenIdsWithoutDataDependencies when they're deleted -// so their query will be run again if they're created again. -emitter.on(`DELETE_PAGE`, action => { - seenIdsWithoutDataDependencies.delete(action.payload.path) -}) - -emitter.on(`CREATE_NODE`, action => { - queuedDirtyActions.push(action) -}) - -emitter.on(`DELETE_NODE`, action => { - queuedDirtyActions.push({ payload: action.payload }) -}) -// /////////////////////////////////////////////////////////////////// -// Calculate dirty static/page queries - -const popExtractedQueries = () => { - const queries = [...extractedQueryIds] - extractedQueryIds.clear() - return queries -} - -const findIdsWithoutDataDependencies = state => { - const allTrackedIds = new Set() - const boundAddToTrackedIds = allTrackedIds.add.bind(allTrackedIds) - state.componentDataDependencies.nodes.forEach(dependenciesOnNode => { - dependenciesOnNode.forEach(boundAddToTrackedIds) - }) - state.componentDataDependencies.connections.forEach( - dependenciesOnConnection => { - dependenciesOnConnection.forEach(boundAddToTrackedIds) +/** + * Calculates the set of dirty query IDs (page.paths, or staticQuery.id's). + * + * Dirty state is tracked in `queries` reducer, here we simply filter + * them from all tracked queries. + */ +const calcDirtyQueryIds = state => { + const { trackedQueries, trackedComponents, deletedQueries } = state.queries + + const queriesWithBabelErrors = new Set() + for (const component of trackedComponents.values()) { + if (hasFlag(component.errors, FLAG_ERROR_EXTRACTION)) { + for (const queryId of component.pages) { + queriesWithBabelErrors.add(queryId) + } } - ) - - // Get list of paths not already tracked and run the queries for these - // paths. - const notTrackedIds = new Set( - [ - ...Array.from(state.pages.values(), p => p.path), - ...[...state.staticQueryComponents.values()].map(c => c.id), - ].filter( - x => !allTrackedIds.has(x) && !seenIdsWithoutDataDependencies.has(x) - ) - ) - - // Add new IDs to our seen array so we don't keep trying to run queries for them. - // Pages without queries can't be tracked. - for (const notTrackedId of notTrackedIds) { - seenIdsWithoutDataDependencies.add(notTrackedId) } - - return notTrackedIds -} - -const popNodeQueries = state => { - const actions = _.uniq(queuedDirtyActions, a => a.payload.id) - const uniqDirties = actions.reduce((dirtyIds, action) => { - const node = action.payload - - if (!node || !node.id || !node.internal.type) return dirtyIds - - // Find components that depend on this node so are now dirty. - if (state.componentDataDependencies.nodes.has(node.id)) { - state.componentDataDependencies.nodes.get(node.id).forEach(n => { - if (n) { - dirtyIds.add(n) - } - }) + // Note: trackedQueries contains both - page and static query ids + const dirtyQueryIds = [] + for (const [queryId, query] of trackedQueries) { + if (deletedQueries.has(queryId)) { + continue } - - // Find connections that depend on this node so are now invalid. - if (state.componentDataDependencies.connections.has(node.internal.type)) { - state.componentDataDependencies.connections - .get(node.internal.type) - .forEach(n => { - if (n) { - dirtyIds.add(n) - } - }) + if (query.dirty > 0 && !queriesWithBabelErrors.has(queryId)) { + dirtyQueryIds.push(queryId) } - - return dirtyIds - }, new Set()) - - boundActionCreators.deleteComponentsDependencies([...uniqDirties]) - - queuedDirtyActions = [] - return uniqDirties -} - -const popNodeAndDepQueries = state => { - const nodeQueries = popNodeQueries(state) - - const noDepQueries = findIdsWithoutDataDependencies(state) - - return _.uniq([...nodeQueries, ...noDepQueries]) -} - -/** - * Calculates the set of dirty query IDs (page.paths, or - * staticQuery.hash's). These are queries that: - * - * - depend on nodes or node collections (via - * `actions.createPageDependency`) that have changed. - * - do NOT have node dependencies. Since all queries should return - * data, then this implies that node dependencies have not been - * tracked, and therefore these queries haven't been run before - * - have been recently extracted (see `./query-watcher.js`) - * - * Note, this function pops queries off internal queues, so it's up - * to the caller to reference the results - */ - -const calcDirtyQueryIds = state => - _.union(popNodeAndDepQueries(state), popExtractedQueries()) - -/** - * Same as `calcDirtyQueryIds`, except that we only include extracted - * queries that depend on nodes or haven't been run yet. We do this - * because the page component reducer/machine always enqueues - * extractedQueryIds but during bootstrap we may not want to run those - * page queries if their data hasn't changed since the last time we - * ran Gatsby. - */ -const calcInitialDirtyQueryIds = state => { - const nodeAndNoDepQueries = popNodeAndDepQueries(state) - - const extractedQueriesThatNeedRunning = _.intersection( - popExtractedQueries(), - nodeAndNoDepQueries - ) - return _.union(extractedQueriesThatNeedRunning, nodeAndNoDepQueries) + } + return dirtyQueryIds } /** @@ -176,7 +60,7 @@ const createStaticQueryJob = (state, queryId) => { const component = state.staticQueryComponents.get(queryId) const { hash, id, query, componentPath } = component return { - id: hash, + id: queryId, hash, query, componentPath, @@ -184,29 +68,6 @@ const createStaticQueryJob = (state, queryId) => { } } -/** - * Creates activity object which: - * - creates actual progress activity if there are any queries that need to be run - * - creates activity-like object that just cancels pending activity if there are no queries to run - */ -const createQueryRunningActivity = (queryJobsCount, parentSpan) => { - if (queryJobsCount) { - const activity = report.createProgress(`run queries`, queryJobsCount, 0, { - id: `query-running`, - parentSpan, - }) - activity.start() - return activity - } else { - return { - done: () => { - report.completeActivity(`query-running`) - }, - tick: () => {}, - } - } -} - const processStaticQueries = async ( queryIds, { state, activity, graphqlRunner, graphqlTracing } @@ -258,120 +119,10 @@ const createPageQueryJob = (state, page) => { } } -// /////////////////////////////////////////////////////////////////// -// Listener for gatsby develop - -// Initialized via `startListening` -let listenerQueue - -/** - * Run any dirty queries. See `calcQueries` for what constitutes a - * dirty query - */ -const runQueuedQueries = () => { - if (listenerQueue) { - const state = store.getState() - const { staticQueryIds, pageQueryIds } = groupQueryIds( - calcDirtyQueryIds(state) - ) - const pages = _.filter(pageQueryIds.map(id => state.pages.get(id))) - const queryJobs = [ - ...staticQueryIds.map(id => createStaticQueryJob(state, id)), - ...pages.map(page => createPageQueryJob(state, page)), - ] - listenerQueue.push(queryJobs) - } -} - -/** - * Starts a background process that processes any dirty queries - * whenever one of the following occurs: - * - * 1. A node has changed (but only after the api call has finished - * running) - * 2. A component query (e.g. by editing a React Component) has - * changed - * - * For what constitutes a dirty query, see `calcQueries` - */ - -const startListeningToDevelopQueue = ({ graphqlTracing } = {}) => { - // We use a queue to process batches of queries so that they are - // processed consecutively - let graphqlRunner = null - const developQueue = queryQueue.createDevelopQueue(() => { - if (!graphqlRunner) { - graphqlRunner = new GraphQLRunner(store, { graphqlTracing }) - } - return graphqlRunner - }) - listenerQueue = new Queue((queryJobs, callback) => { - const activity = createQueryRunningActivity(queryJobs.length) - - const onFinish = (...arg) => { - pageDataUtil.enqueueFlush() - activity.done() - return callback(...arg) - } - - return queryQueue - .processBatch(developQueue, queryJobs, activity) - .then(() => onFinish(null)) - .catch(onFinish) - }) - - emitter.on(`API_RUNNING_START`, () => { - report.pendingActivity({ id: `query-running` }) - }) - - emitter.on(`API_RUNNING_QUEUE_EMPTY`, runQueuedQueries) - ;[ - `DELETE_CACHE`, - `CREATE_NODE`, - `DELETE_NODE`, - `DELETE_NODES`, - `SET_SCHEMA_COMPOSER`, - `SET_SCHEMA`, - `ADD_FIELD_TO_NODE`, - `ADD_CHILD_NODE_TO_PARENT_NODE`, - ].forEach(eventType => { - emitter.on(eventType, event => { - graphqlRunner = null - }) - }) -} - -const enqueueExtractedQueryId = pathname => { - extractedQueryIds.add(pathname) -} - -const getPagesForComponent = componentPath => { - const state = store.getState() - return [...state.pages.values()].filter( - p => p.componentPath === componentPath - ) -} - -const enqueueExtractedPageComponent = componentPath => { - const pages = getPagesForComponent(componentPath) - // Remove page data dependencies before re-running queries because - // the changing of the query could have changed the data dependencies. - // Re-running the queries will add back data dependencies. - boundActionCreators.deleteComponentsDependencies( - pages.map(p => p.path || p.id) - ) - pages.forEach(page => enqueueExtractedQueryId(page.path)) - runQueuedQueries() -} - module.exports = { - calcInitialDirtyQueryIds, + calcInitialDirtyQueryIds: calcDirtyQueryIds, calcDirtyQueryIds, processPageQueries, processStaticQueries, groupQueryIds, - startListeningToDevelopQueue, - runQueuedQueries, - enqueueExtractedQueryId, - enqueueExtractedPageComponent, } diff --git a/packages/gatsby/src/query/query-runner.ts b/packages/gatsby/src/query/query-runner.ts index 33930f1013e46..58781ed238040 100644 --- a/packages/gatsby/src/query/query-runner.ts +++ b/packages/gatsby/src/query/query-runner.ts @@ -75,6 +75,12 @@ export const queryRunner = async ( return promise } + boundActionCreators.queryStart({ + path: queryJob.id, + componentPath: queryJob.componentPath, + isPage: queryJob.isPage, + }) + // Run query let result: IExecutionResult // Nothing to do if the query doesn't exist. diff --git a/packages/gatsby/src/query/query-watcher.ts b/packages/gatsby/src/query/query-watcher.ts index 8f378761cb277..3c78814fa254e 100644 --- a/packages/gatsby/src/query/query-watcher.ts +++ b/packages/gatsby/src/query/query-watcher.ts @@ -19,7 +19,6 @@ import { boundActionCreators } from "../redux/actions" import { IGatsbyStaticQueryComponents } from "../redux/types" import queryCompiler from "./query-compiler" import report from "gatsby-cli/lib/reporter" -import queryUtil from "./" import { getGatsbyDependents } from "../utils/gatsby-dependents" const debug = require(`debug`)(`gatsby:query-watcher`) @@ -73,7 +72,6 @@ const handleComponentsWithRemovedQueries = ( type: `REMOVE_STATIC_QUERY`, payload: c.id, }) - boundActionCreators.deleteComponentsDependencies([c.id]) } }) } @@ -112,9 +110,6 @@ const handleQuery = ( isNewQuery ? `was added` : `has changed` }.` ) - - boundActionCreators.deleteComponentsDependencies([query.id]) - queryUtil.enqueueExtractedQueryId(query.id) } return true } @@ -278,8 +273,6 @@ export const updateStateAndRunQueries = async ( `) } - - queryUtil.runQueuedQueries() } export const extractQueries = ({ diff --git a/packages/gatsby/src/query/queue.ts b/packages/gatsby/src/query/queue.ts index 562284416cc71..ab3bf57ae0999 100644 --- a/packages/gatsby/src/query/queue.ts +++ b/packages/gatsby/src/query/queue.ts @@ -62,7 +62,7 @@ const createDevelopQueue = (getRunner: () => GraphQLRunner): Queue => { if (!queryJob.isPage) { websocketManager.emitStaticQueryData({ result, - id: queryJob.id, + id: queryJob.hash, }) } diff --git a/packages/gatsby/src/redux/__tests__/__snapshots__/index.js.snap b/packages/gatsby/src/redux/__tests__/__snapshots__/index.js.snap index 6d891ee2744ce..fd02c438aad2f 100644 --- a/packages/gatsby/src/redux/__tests__/__snapshots__/index.js.snap +++ b/packages/gatsby/src/redux/__tests__/__snapshots__/index.js.snap @@ -2,10 +2,6 @@ exports[`redux db should write redux cache to disk 1`] = ` Object { - "componentDataDependencies": Object { - "connections": Map {}, - "nodes": Map {}, - }, "components": Map { "/Users/username/dev/site/src/templates/my-sweet-new-page.js" => Object { "componentPath": "/Users/username/dev/site/src/templates/my-sweet-new-page.js", @@ -58,6 +54,26 @@ Object { "pagePaths": Set {}, "templatePaths": Set {}, }, + "queries": Object { + "byConnection": Map {}, + "byNode": Map {}, + "deletedQueries": Set {}, + "trackedComponents": Map { + "/Users/username/dev/site/src/templates/my-sweet-new-page.js" => Object { + "componentPath": "/Users/username/dev/site/src/templates/my-sweet-new-page.js", + "errors": 0, + "pages": Set { + "/my-sweet-new-page/", + }, + "query": "", + }, + }, + "trackedQueries": Map { + "/my-sweet-new-page/" => Object { + "dirty": 1, + }, + }, + }, "staticQueriesByTemplate": Map {}, "staticQueryComponents": Map {}, "status": Object { diff --git a/packages/gatsby/src/redux/actions/add-page-dependency.ts b/packages/gatsby/src/redux/actions/add-page-dependency.ts index 1b98692c970b2..7991b31d93521 100644 --- a/packages/gatsby/src/redux/actions/add-page-dependency.ts +++ b/packages/gatsby/src/redux/actions/add-page-dependency.ts @@ -11,7 +11,7 @@ export const createPageDependency = ({ nodeId: string connection?: string }): void => { - const { componentDataDependencies } = store.getState() + const { queries } = store.getState() // Check that the dependencies aren't already recorded so we // can avoid creating lots of very noisy actions. @@ -22,8 +22,8 @@ export const createPageDependency = ({ } if ( nodeId && - componentDataDependencies.nodes.has(nodeId) && - componentDataDependencies.nodes.get(nodeId)!.has(path) + queries.byNode.has(nodeId) && + queries.byNode.get(nodeId)!.has(path) ) { nodeDependencyExists = true } @@ -32,8 +32,8 @@ export const createPageDependency = ({ } if ( connection && - componentDataDependencies.connections.has(connection) && - componentDataDependencies.connections.get(connection)!.has(path) + queries.byConnection.has(connection) && + queries.byConnection.get(connection)!.has(path) ) { connectionDependencyExists = true } diff --git a/packages/gatsby/src/redux/actions/internal.ts b/packages/gatsby/src/redux/actions/internal.ts index 9a882031e7cb1..c3e989f882e7f 100644 --- a/packages/gatsby/src/redux/actions/internal.ts +++ b/packages/gatsby/src/redux/actions/internal.ts @@ -18,6 +18,8 @@ import { ISetSiteConfig, IDefinitionMeta, ISetGraphQLDefinitionsAction, + IQueryStartAction, + IApiFinishedAction, } from "../types" import { gatsbyConfigSchema } from "../../joi-schemas/joi" @@ -84,6 +86,15 @@ export const replaceComponentQuery = ({ } } +export const apiFinished = ( + payload: IApiFinishedAction["payload"] +): IApiFinishedAction => { + return { + type: `API_FINISHED`, + payload, + } +} + /** * When the query watcher extracts a "static" GraphQL query from * components, it calls this to store the query with its component. @@ -225,6 +236,19 @@ export const pageQueryRun = ( } } +export const queryStart = ( + { path, componentPath, isPage }, + plugin: IGatsbyPlugin, + traceId?: string +): IQueryStartAction => { + return { + type: `QUERY_START`, + plugin, + traceId, + payload: { path, componentPath, isPage }, + } +} + /** * Remove jobs which are marked as stale (inputPath doesn't exists) * @private diff --git a/packages/gatsby/src/redux/index.ts b/packages/gatsby/src/redux/index.ts index b753e48567157..397f1996c4747 100644 --- a/packages/gatsby/src/redux/index.ts +++ b/packages/gatsby/src/redux/index.ts @@ -91,7 +91,6 @@ export const saveState = (): void => { return writeToCache({ nodes: state.nodes, status: state.status, - componentDataDependencies: state.componentDataDependencies, components: state.components, jobsV2: state.jobsV2, staticQueryComponents: state.staticQueryComponents, @@ -100,6 +99,7 @@ export const saveState = (): void => { pageData: state.pageData, pendingPageDataWrites: state.pendingPageDataWrites, staticQueriesByTemplate: state.staticQueriesByTemplate, + queries: state.queries, }) } diff --git a/packages/gatsby/src/redux/machines/__tests__/page-component.ts b/packages/gatsby/src/redux/machines/__tests__/page-component.ts deleted file mode 100644 index ccaafeb853600..0000000000000 --- a/packages/gatsby/src/redux/machines/__tests__/page-component.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { interpret, Interpreter } from "xstate" - -import { componentMachine, IContext, IState, IEvent } from "../page-component" - -jest.mock(`../../../query`) -const { enqueueExtractedQueryId, runQueuedQueries } = require(`../../../query`) - -const getService = (args = {}): Interpreter => - interpret( - componentMachine.withContext({ - componentPath: `/a/path.js`, - query: ``, - pages: new Set([`/`]), - isInBootstrap: true, - ...args, - }) - ).start() - -const sleep = (delay = 50): Promise => - new Promise(resolve => setTimeout(resolve, delay)) - -describe(`bootstrap`, () => { - beforeEach(() => { - enqueueExtractedQueryId.mockClear() - runQueuedQueries.mockClear() - }) - - it(`handles not running queries during bootstrap`, () => { - const service = getService() - // Initial state - expect(service.state.value).toEqual(`inactiveWhileBootstrapping`) - - // Query extracted - service.send({ type: `QUERY_CHANGED`, query: `yo` }) - expect(service.state.value).toEqual(`runningPageQueries`) - expect(service.state.context.query).toEqual(`yo`) - - // Queries complete - service.send(`QUERIES_COMPLETE`) - expect(service.state.value).toEqual(`idle`) - - // Bootstrapped finished - service.send(`BOOTSTRAP_FINISHED`) - expect(service.state.value).toEqual(`idle`) - expect(service.state.context.isInBootstrap).toEqual(false) - }) - - it(`won't run queries if the page component has a JS error`, () => { - const service = getService({ isInBootstrap: false }) - - service.send(`QUERY_EXTRACTION_BABEL_ERROR`) - expect(service.state.value).toEqual(`queryExtractionBabelError`) - service.send(`QUERY_CHANGED`) - expect(service.state.value).toEqual(`queryExtractionBabelError`) - }) - - it(`won't queue extra query when page if new page is created in bootstrap`, async () => { - const service = getService() - service.send({ type: `NEW_PAGE_CREATED`, path: `/test` }) - // there is setTimeout in action handler for `NEW_PAGE_CREATED` - await sleep() - expect(runQueuedQueries).not.toBeCalled() - }) - - it(`will queue query when page if new page is created after bootstrap`, async () => { - const service = getService({ isInBootstrap: false }) - const path = `/test` - service.send({ type: `NEW_PAGE_CREATED`, path }) - // there is setTimeout in action handler for `NEW_PAGE_CREATED` - await sleep() - expect(runQueuedQueries).toBeCalledWith(path) - }) - - it(`will queue query when page context is changed`, async () => { - const service = getService({ isInBootstrap: false }) - service.send({ type: `PAGE_CONTEXT_MODIFIED`, path: `/a/test.md` }) - // there is setTimeout in action handler for `CONTEXT_CHANGES` - await sleep() - expect(enqueueExtractedQueryId).toBeCalledWith(`/a/test.md`) - }) -}) diff --git a/packages/gatsby/src/redux/machines/page-component.ts b/packages/gatsby/src/redux/machines/page-component.ts deleted file mode 100644 index 6c25a18015c4f..0000000000000 --- a/packages/gatsby/src/redux/machines/page-component.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { Machine as machine, assign } from "xstate" - -export interface IContext { - isInBootstrap: boolean - componentPath: string - query: string - pages: Set -} - -export interface IState { - states: { - inactive: {} - inactiveWhileBootstrapping: {} - queryExtractionGraphQLError: {} - queryExtractionBabelError: {} - runningPageQueries: {} - idle: {} - } -} - -/** - * Stricter types for actions are not possible - * as we have different payloads that would require casting. - * The current approach prevents this but makes all payloads optional. - * See https://github.com/gatsbyjs/gatsby/pull/23277#issuecomment-625425023 - */ - -type ActionTypes = - | "BOOTSTRAP_FINISHED" - | "DELETE_PAGE" - | "NEW_PAGE_CREATED" - | "PAGE_CONTEXT_MODIFIED" - | "QUERY_EXTRACTION_GRAPHQL_ERROR" - | "QUERY_EXTRACTION_BABEL_ERROR" - | "QUERY_EXTRACTION_BABEL_SUCCESS" - | "QUERY_CHANGED" - | "QUERY_DID_NOT_CHANGE" - | "QUERIES_COMPLETE" - -export interface IEvent { - type: ActionTypes - path?: string - query?: string - page?: { path: string } -} - -const defaultContext: IContext = { - isInBootstrap: true, - componentPath: ``, - query: ``, - pages: new Set(``), -} - -export const componentMachine = machine( - { - id: `pageComponents`, - initial: `inactive`, - context: defaultContext, - on: { - BOOTSTRAP_FINISHED: { - actions: `setBootstrapFinished`, - }, - DELETE_PAGE: { - actions: `deletePage`, - }, - NEW_PAGE_CREATED: { - actions: `setPage`, - }, - PAGE_CONTEXT_MODIFIED: { - actions: `rerunPageQuery`, - }, - QUERY_EXTRACTION_GRAPHQL_ERROR: `queryExtractionGraphQLError`, - QUERY_EXTRACTION_BABEL_ERROR: `queryExtractionBabelError`, - }, - states: { - inactive: { - // Transient transition - // Will transition to either 'inactiveWhileBootstrapping' or idle - // immediately upon entering 'inactive' state if the condition is met. - always: [ - { target: `inactiveWhileBootstrapping`, cond: `isBootstrapping` }, - { target: `idle`, cond: `isNotBootstrapping` }, - ], - }, - inactiveWhileBootstrapping: { - on: { - BOOTSTRAP_FINISHED: { - target: `idle`, - actions: `setBootstrapFinished`, - }, - QUERY_CHANGED: `runningPageQueries`, - }, - }, - queryExtractionGraphQLError: { - on: { - QUERY_DID_NOT_CHANGE: `idle`, - QUERY_CHANGED: `runningPageQueries`, - }, - }, - queryExtractionBabelError: { - on: { - QUERY_EXTRACTION_BABEL_SUCCESS: `idle`, - }, - }, - runningPageQueries: { - onEntry: [`setQuery`, `runPageComponentQueries`], - on: { - QUERIES_COMPLETE: `idle`, - }, - }, - idle: { - on: { - QUERY_CHANGED: `runningPageQueries`, - }, - }, - }, - }, - { - guards: { - isBootstrapping: (context): boolean => context.isInBootstrap, - isNotBootstrapping: (context): boolean => !context.isInBootstrap, - }, - actions: { - rerunPageQuery: (_ctx, event): void => { - const queryUtil = require(`../../query`) - // Wait a bit as calling this function immediately triggers - // an Action call which Redux squawks about. - setTimeout(() => { - queryUtil.enqueueExtractedQueryId(event.path) - }, 0) - }, - runPageComponentQueries: (context): void => { - const queryUtil = require(`../../query`) - // Wait a bit as calling this function immediately triggers - // an Action call which Redux squawks about. - setTimeout(() => { - queryUtil.enqueueExtractedPageComponent(context.componentPath) - }, 0) - }, - setQuery: assign({ - query: (ctx, event): string => { - if (typeof event.query !== `undefined` && event.query !== null) { - return event.query - } else { - return ctx.query - } - }, - }), - setPage: assign({ - pages: (ctx, event) => { - if (event.path) { - const queryUtil = require(`../../query`) - // Wait a bit as calling this function immediately triggers - // an Action call which Redux squawks about. - setTimeout(() => { - if (!ctx.isInBootstrap) { - queryUtil.enqueueExtractedQueryId(event.path) - queryUtil.runQueuedQueries(event.path) - } - }, 0) - ctx.pages.add(event.path) - return ctx.pages - } else { - return ctx.pages - } - }, - }), - deletePage: assign({ - pages: (ctx, event) => { - ctx.pages.delete(event.page!.path) - return ctx.pages - }, - }), - setBootstrapFinished: assign({ - isInBootstrap: false, - }), - }, - } -) diff --git a/packages/gatsby/src/redux/reducers/__tests__/__snapshots__/page-data-dependencies.ts.snap b/packages/gatsby/src/redux/reducers/__tests__/__snapshots__/queries.ts.snap similarity index 67% rename from packages/gatsby/src/redux/reducers/__tests__/__snapshots__/page-data-dependencies.ts.snap rename to packages/gatsby/src/redux/reducers/__tests__/__snapshots__/queries.ts.snap index f1657b22631a0..252b08c409619 100644 --- a/packages/gatsby/src/redux/reducers/__tests__/__snapshots__/page-data-dependencies.ts.snap +++ b/packages/gatsby/src/redux/reducers/__tests__/__snapshots__/queries.ts.snap @@ -2,15 +2,18 @@ exports[`add page data dependency lets you add both a node and connection in one action 1`] = ` Object { - "connections": Map { + "byConnection": Map { "MarkdownRemark" => Set { "/hi/", }, }, - "nodes": Map { + "byNode": Map { "SuperCoolNode" => Set { "/hi/", }, }, + "deletedQueries": Set {}, + "trackedComponents": Map {}, + "trackedQueries": Map {}, } `; diff --git a/packages/gatsby/src/redux/reducers/__tests__/page-data-dependencies.ts b/packages/gatsby/src/redux/reducers/__tests__/page-data-dependencies.ts deleted file mode 100644 index b3b7a098baf9a..0000000000000 --- a/packages/gatsby/src/redux/reducers/__tests__/page-data-dependencies.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { componentDataDependenciesReducer as reducer } from "../component-data-dependencies" - -import { ICreatePageDependencyAction } from "../../types" - -describe(`add page data dependency`, () => { - it(`lets you add a node dependency`, () => { - const action: ICreatePageDependencyAction = { - type: `CREATE_COMPONENT_DEPENDENCY`, - payload: { - path: `/hi/`, - nodeId: `123`, - }, - } - - expect(reducer(undefined, action)).toEqual({ - connections: new Map(), - nodes: new Map([[`123`, new Set([`/hi/`])]]), - }) - }) - it(`lets you add a node dependency to multiple paths`, () => { - const action: ICreatePageDependencyAction = { - type: `CREATE_COMPONENT_DEPENDENCY`, - payload: { - path: `/hi/`, - nodeId: `1.2.3`, - }, - } - const action2: ICreatePageDependencyAction = { - type: `CREATE_COMPONENT_DEPENDENCY`, - payload: { - path: `/hi2/`, - nodeId: `1.2.3`, - }, - } - const action3: ICreatePageDependencyAction = { - type: `CREATE_COMPONENT_DEPENDENCY`, - payload: { - path: `/blog/`, - nodeId: `1.2.3`, - }, - } - - let state = reducer(undefined, action) - state = reducer(state, action2) - state = reducer(state, action3) - - expect(state).toEqual({ - connections: new Map(), - nodes: new Map([[`1.2.3`, new Set([`/hi/`, `/hi2/`, `/blog/`])]]), - }) - }) - it(`lets you add a connection dependency`, () => { - const action: ICreatePageDependencyAction = { - type: `CREATE_COMPONENT_DEPENDENCY`, - payload: { - path: `/hi/`, - connection: `Markdown.Remark`, - }, - } - const action2: ICreatePageDependencyAction = { - type: `CREATE_COMPONENT_DEPENDENCY`, - payload: { - path: `/hi2/`, - connection: `Markdown.Remark`, - }, - } - - let state = reducer(undefined, action) - state = reducer(state, action2) - - expect(state).toEqual({ - connections: new Map([[`Markdown.Remark`, new Set([`/hi/`, `/hi2/`])]]), - nodes: new Map(), - }) - }) - it(`removes duplicate paths`, () => { - const action: ICreatePageDependencyAction = { - type: `CREATE_COMPONENT_DEPENDENCY`, - payload: { - path: `/hi/`, - nodeId: `1`, - connection: `MarkdownRemark`, - }, - } - const action2: ICreatePageDependencyAction = { - type: `CREATE_COMPONENT_DEPENDENCY`, - payload: { - path: `/hi2/`, - nodeId: `1`, - connection: `MarkdownRemark`, - }, - } - - let state = reducer(undefined, action) - // Do it again - state = reducer(state, action) - // Add different action - state = reducer(state, action2) - - expect(state.connections.get(`MarkdownRemark`)?.size).toEqual(2) - expect(state.nodes.get(`1`)?.size).toEqual(2) - }) - it(`lets you add both a node and connection in one action`, () => { - const action: ICreatePageDependencyAction = { - type: `CREATE_COMPONENT_DEPENDENCY`, - payload: { - path: `/hi/`, - connection: `MarkdownRemark`, - nodeId: `SuperCoolNode`, - }, - } - - const state = reducer(undefined, action) - - expect(state).toMatchSnapshot() - }) - // it(`removes node/page connections when the node is deleted`, () => { - // const action = { - // type: `CREATE_COMPONENT_DEPENDENCY`, - // payload: { - // path: `/hi/`, - // nodeId: `123`, - // }, - // } - - // let state = reducer(undefined, action) - - // const deleteNodeAction = { - // type: `DELETE_NODE`, - // payload: 123, - // } - - // state = reducer(state, deleteNodeAction) - - // expect(state).toEqual({ - // connections: {}, - // nodes: {}, - // }) - // }) - // it(`removes node/page connections when multiple nodes are deleted`, () => { - // const action = { - // type: `CREATE_COMPONENT_DEPENDENCY`, - // payload: { - // path: `/hi/`, - // nodeId: `123`, - // }, - // } - // const action2 = { - // type: `CREATE_COMPONENT_DEPENDENCY`, - // payload: { - // path: `/hi2/`, - // nodeId: `1234`, - // }, - // } - - // let state = reducer(undefined, action) - // state = reducer(state, action2) - - // const deleteNodeAction = { - // type: `DELETE_NODES`, - // payload: [123, 1234], - // } - - // state = reducer(state, deleteNodeAction) - - // expect(state).toEqual({ - // connections: {}, - // nodes: {}, - // }) - // }) -}) diff --git a/packages/gatsby/src/redux/reducers/__tests__/queries.ts b/packages/gatsby/src/redux/reducers/__tests__/queries.ts new file mode 100644 index 0000000000000..b09c50d541202 --- /dev/null +++ b/packages/gatsby/src/redux/reducers/__tests__/queries.ts @@ -0,0 +1,909 @@ +import { + FLAG_DIRTY_PAGE, + FLAG_DIRTY_TEXT, + FLAG_ERROR_EXTRACTION, + queriesReducer as reducer, +} from "../queries" +import { + queryStart, + pageQueryRun, + replaceStaticQuery, + queryExtracted, + queryExtractionBabelError, + queryExtractionGraphQLError, + queryExtractedBabelSuccess, +} from "../../actions/internal" + +import { + ICreatePageAction, + ICreatePageDependencyAction, + IGatsbyPage, + IGatsbyState, + IPageQueryRunAction, + IQueryStartAction, +} from "../../types" + +import { cloneDeep } from "lodash" + +type QueriesState = IGatsbyState["queries"] + +let state: QueriesState +let Pages +let StaticQueries +let ComponentQueries +beforeEach(() => { + state = reducer(undefined, `@@setup` as any) + Pages = { + foo: { + path: `/foo`, + componentPath: `/foo.js`, + component: `/foo.js`, + }, + bar: { + path: `/bar`, + componentPath: `/bar.js`, + component: `/bar.js`, + }, + bar2: { + path: `/bar2`, + componentPath: `/bar.js`, + component: `/bar.js`, + }, + } + ComponentQueries = { + foo: { + componentPath: `/foo.js`, + query: `{ allFoo { nodes { foo } } }`, + }, + fooEdited: { + componentPath: `/foo.js`, + query: `{ allFoo { edges { nodes { foo } } } }`, + }, + bar: { + componentPath: `/bar.js`, + query: `{ allBar { nodes { bar } } }`, + }, + } + StaticQueries = { + q1: { + id: `sq--q1`, + name: `q1-name`, + componentPath: `/static-query-component1.js`, + query: `{ allFooStatic { nodes { foo } } }`, + hash: `q1-hash`, + }, + q2: { + id: `sq--q2`, + name: `q2-name`, + componentPath: `/static-query-component2.js`, + query: `{ allBarStatic { nodes { bar } } }`, + hash: `q2-hash`, + }, + } +}) + +it(`has expected initial state`, () => { + expect(state).toMatchInlineSnapshot(` + Object { + "byConnection": Map {}, + "byNode": Map {}, + "deletedQueries": Set {}, + "trackedComponents": Map {}, + "trackedQueries": Map {}, + } + `) +}) + +describe(`create page`, () => { + it(`starts tracking page query`, () => { + state = createPage(state, Pages.foo) + expect(state.trackedQueries.has(`/foo`)) + }) + + it(`marks new page query as dirty`, () => { + state = createPage(state, Pages.foo) + state = createPage(state, Pages.bar) + + expect(state).toMatchObject({ + trackedQueries: new Map([ + [`/foo`, { dirty: FLAG_DIRTY_PAGE }], + [`/bar`, { dirty: FLAG_DIRTY_PAGE }], + ]), + }) + }) + + it(`does not mark existing page query as dirty`, () => { + state = createPage(state, Pages.foo) + state = runQuery(state, { path: Pages.foo.path }) + expect(state.trackedQueries.get(`/foo`)?.dirty).toEqual(0) // sanity-check + state = createPage(state, Pages.foo) + + expect(state.trackedQueries.get(`/foo`)).toEqual({ + dirty: 0, + }) + }) + + it(`marks existing page query with modified context as dirty`, () => { + state = createPage(state, Pages.foo) + state = runQuery(state, { path: Pages.foo.path }) + expect(state.trackedQueries.get(`/foo`)?.dirty).toEqual(0) // sanity-check + state = createPage(state, Pages.foo, { contextModified: true }) + + expect(state.trackedQueries.get(`/foo`)).toEqual({ + dirty: FLAG_DIRTY_PAGE, + }) + }) + + it(`binds every page with corresponding component`, () => { + state = createPage(state, Pages.foo) + state = createPage(state, Pages.bar) + + expect(Array.from(state.trackedComponents.keys())).toEqual([ + `/foo.js`, + `/bar.js`, + ]) + expect(state.trackedComponents.get(`/foo.js`)).toEqual({ + componentPath: `/foo.js`, + pages: new Set([`/foo`]), + query: ``, + errors: 0, + }) + expect(state.trackedComponents.get(`/bar.js`)).toEqual({ + componentPath: `/bar.js`, + pages: new Set([`/bar`]), + query: ``, + errors: 0, + }) + }) + + it(`does not affect data tracking`, () => { + state = createPage(state, Pages.foo) + + expect(state).toMatchObject({ + byNode: new Map(), + byConnection: new Map(), + }) + }) + + it(`cancels scheduled page deletion`, () => { + state = createPage(state, Pages.foo) + state = deletePage(state, Pages.foo) + // sanity check + expect(state.deletedQueries).toEqual(new Set([Pages.foo.path])) + + state = createPage(state, Pages.foo) + expect(state.deletedQueries).toEqual(new Set([])) + }) +}) + +describe(`delete page`, () => { + beforeEach(() => { + state = createPage(state, Pages.foo) + state = createPage(state, Pages.bar) + + const action1: ICreatePageDependencyAction = { + type: `CREATE_COMPONENT_DEPENDENCY`, + payload: { + path: Pages.foo.path, + nodeId: `123`, + }, + } + const action2: ICreatePageDependencyAction = { + type: `CREATE_COMPONENT_DEPENDENCY`, + payload: { + path: Pages.bar.path, + connection: `Bar`, + }, + } + state = reducer(state, action1) + state = reducer(state, action2) + }) + + it(`has expected baseline`, () => { + expect(state).toMatchObject({ + byConnection: new Map([[`Bar`, new Set([`/bar`])]]), + byNode: new Map([[`123`, new Set([`/foo`])]]), + deletedQueries: new Set(), + }) + expect(state.trackedComponents.get(`/foo.js`)).toMatchObject({ + pages: new Set([`/foo`]), + }) + expect(state.trackedComponents.get(`/bar.js`)).toMatchObject({ + pages: new Set([`/bar`]), + }) + expect(state.trackedQueries.has(`/foo`)).toEqual(true) + expect(state.trackedQueries.has(`/bar`)).toEqual(true) + }) + + it(`schedules page query for deletion from tracked queries`, () => { + state = deletePage(state, Pages.foo) + + // Pages still exist just scheduled for deletion: + expect(state.deletedQueries.has(`/foo`)).toEqual(true) + expect(state.deletedQueries.has(`/bar`)).toEqual(false) + + expect(state.trackedQueries.has(`/foo`)).toEqual(true) + expect(state.trackedComponents.get(`/foo.js`)).toMatchObject({ + pages: new Set([`/foo`]), + }) + + expect(state.trackedQueries.has(`/bar`)).toEqual(true) + expect(state.trackedComponents.get(`/bar.js`)).toMatchObject({ + pages: new Set([`/bar`]), + }) + }) + + it(`does not affect data tracking`, () => { + state = deletePage(state, Pages.foo) + + expect(state).toMatchObject({ + byNode: new Map([[`123`, new Set([`/foo`])]]), + byConnection: new Map([[`Bar`, new Set([`/bar`])]]), + }) + }) +}) + +describe(`replace static query`, () => { + it(`starts tracking new static query`, () => { + state = reducer(state, replaceStaticQuery(StaticQueries.q1)) + state = reducer(state, replaceStaticQuery(StaticQueries.q2)) + + expect(Array.from(state.trackedQueries.keys())).toEqual([ + `sq--q1`, + `sq--q2`, + ]) + }) + + it(`marks new static query text as dirty`, () => { + state = reducer(state, replaceStaticQuery(StaticQueries.q1)) + + expect(state).toMatchObject({ + trackedQueries: new Map([[`sq--q1`, { dirty: FLAG_DIRTY_TEXT }]]), + }) + }) + + it(`marks existing static query text as dirty`, () => { + // We do this even if actual static query text hasn't changed + // (assuming this action is only called when query text changes internally) + // FIXME: probably we shouldn't invalidate query text if it didn't actually change + state = reducer(state, replaceStaticQuery(StaticQueries.q1)) + state = runQuery(state, { + path: StaticQueries.q1.id, + componentPath: StaticQueries.q1.componentPath, + isPage: false, + }) + expect(state.trackedQueries.get(`sq--q1`)?.dirty).toEqual(0) // sanity-check + + state = reducer(state, replaceStaticQuery(StaticQueries.q1)) + expect(state.trackedQueries.get(`sq--q1`)).toEqual({ + dirty: FLAG_DIRTY_TEXT, + }) + }) + + it(`cancels scheduled static query deletion`, () => { + state = reducer(state, replaceStaticQuery(StaticQueries.q1)) + state = removeStaticQuery(state, StaticQueries.q1.id) + expect(state.deletedQueries.has(StaticQueries.q1.id)).toEqual(true) // sanity check + + state = reducer(state, replaceStaticQuery(StaticQueries.q1)) + expect(state.deletedQueries.has(StaticQueries.q1.id)).toEqual(false) + }) + + // TODO: currently we track static query components in a separate "static-query-components" reducer + // Instead we should track all query relations uniformly in "queries" reducer. + // Then this test will make sense + it.skip(`bind static query with corresponding component`, () => { + state = reducer(state, replaceStaticQuery(StaticQueries.q1)) + state = reducer(state, replaceStaticQuery(StaticQueries.q2)) + + expect(Array.from(state.trackedComponents.keys())).toEqual([ + `/static-query-component1.js`, + `/static-query-component2.js`, + ]) + expect(state.trackedComponents.get(`/static-query-component1.js`)).toEqual({ + componentPath: `/static-query-component1.js`, + pages: new Set(), + staticQueries: new Set([`sq--q1`]), + query: ``, + }) + expect(state.trackedComponents.get(`/static-query-component2.js`)).toEqual({ + componentPath: `/static-query-component2.js`, + pages: new Set(), + staticQueries: new Set([`sq--q2`]), + query: ``, + }) + }) +}) + +describe(`remove static query`, () => { + beforeEach(() => { + state = reducer(state, replaceStaticQuery(StaticQueries.q1)) + state = reducer(state, replaceStaticQuery(StaticQueries.q2)) + }) + + it(`schedules static query for deletion from tracked queries`, () => { + state = removeStaticQuery(state, StaticQueries.q1.id) + expect(Array.from(state.deletedQueries.keys())).toEqual([`sq--q1`]) + expect(Array.from(state.trackedQueries.keys())).toEqual([ + `sq--q1`, + `sq--q2`, + ]) + }) +}) + +describe(`createPages API end`, () => { + beforeEach(() => { + state = createPage(state, Pages.foo) + state = createPage(state, Pages.bar) + state = reducer(state, replaceStaticQuery(StaticQueries.q1)) + + const dataDependencies = [ + { path: Pages.foo.path, nodeId: `123` }, + { path: Pages.foo.path, connection: `Test` }, + { path: Pages.bar.path, nodeId: `123` }, + { path: Pages.bar.path, connection: `Test` }, + { path: StaticQueries.q1.id, connection: `Test` }, + { path: StaticQueries.q1.id, nodeId: `123` }, + ] + for (const payload of dataDependencies) { + state = reducer(state, { + type: `CREATE_COMPONENT_DEPENDENCY`, + payload, + }) + } + state = deletePage(state, Pages.foo) + state = removeStaticQuery(state, StaticQueries.q1.id) + }) + + it(`has expected baseline`, () => { + expect(state).toMatchObject({ + byConnection: new Map([[`Test`, new Set([`/foo`, `/bar`, `sq--q1`])]]), + byNode: new Map([[`123`, new Set([`/foo`, `/bar`, `sq--q1`])]]), + deletedQueries: new Set([`/foo`, `sq--q1`]), + }) + expect(state.trackedComponents.get(`/foo.js`)).toMatchObject({ + pages: new Set([`/foo`]), + }) + expect(state.trackedComponents.get(`/bar.js`)).toMatchObject({ + pages: new Set([`/bar`]), + }) + expect(state.trackedQueries.has(`/foo`)).toEqual(true) + expect(state.trackedQueries.has(`/bar`)).toEqual(true) + expect(state.trackedQueries.has(`sq--q1`)).toEqual(true) + }) + + it(`doesn't alter state on any API other than createPages`, () => { + const baseLine = cloneDeep(state) + state = reducer(state, { + type: `API_FINISHED`, + payload: { apiName: `sourceNodes` }, + }) + expect(state).toEqual(baseLine) + }) + + it(`removes previously deleted page query from tracked component pages`, () => { + state = reducer(state, { + type: `API_FINISHED`, + payload: { apiName: `createPages` }, + }) + + expect(state.trackedComponents.get(`/foo.js`)).toMatchObject({ + pages: new Set([]), + }) + expect(state.trackedComponents.get(`/bar.js`)).toMatchObject({ + pages: new Set([`/bar`]), + }) + expect(state.deletedQueries).toEqual(new Set([])) + }) + + it(`removes previously deleted page query from tracked queries`, () => { + state = reducer(state, { + type: `API_FINISHED`, + payload: { apiName: `createPages` }, + }) + + expect(state.trackedQueries.has(`/foo`)).toEqual(false) + expect(state.trackedQueries.has(`/bar`)).toEqual(true) + }) + + it(`clears data dependencies for previously deleted queries`, () => { + state = reducer(state, { + type: `API_FINISHED`, + payload: { apiName: `createPages` }, + }) + + expect(state.byNode.get(`123`)).toEqual(new Set([`/bar`])) + expect(state.byConnection.get(`Test`)).toEqual(new Set([`/bar`])) + }) + + // TODO: see a note in the "replace static query" + it.skip(`removes binding of static query with corresponding component`, () => { + state = removeStaticQuery(state, StaticQueries.q1.id) + + expect( + state.trackedComponents.get(`/static-query-component1.js`) + ).toMatchObject({ + staticQueries: new Set([]), + }) + // sanity check: + expect( + state.trackedComponents.get(`/static-query-component2.js`) + ).toMatchObject({ + staticQueries: new Set([`sq--q2`]), + }) + }) +}) + +describe(`query extraction`, () => { + // QUERY_EXTRACTED is only called for page queries + // static queries are handled separately via REPLACE_STATIC_QUERY 🤷‍ + + beforeEach(() => { + state = createPage(state, Pages.foo) + state = createPage(state, Pages.bar) + state = createPage(state, Pages.bar2) + }) + + it(`saves query text on the first extraction`, () => { + state = reducer(state, queryExtracted(ComponentQueries.foo, {} as any)) + + expect(state.trackedComponents.get(`/foo.js`)).toMatchObject({ + componentPath: `/foo.js`, + query: `{ allFoo { nodes { foo } } }`, + }) + }) + + it(`marks all page queries associated with the component as dirty on the first run`, () => { + state = reducer(state, queryExtracted(ComponentQueries.bar, {} as any)) + + expect(state.trackedQueries.get(`/bar`)).toEqual({ + dirty: FLAG_DIRTY_PAGE | FLAG_DIRTY_TEXT, + }) + expect(state.trackedQueries.get(`/bar2`)).toEqual({ + dirty: FLAG_DIRTY_PAGE | FLAG_DIRTY_TEXT, + }) + // Sanity check + expect(state.trackedQueries.get(`/foo`)).toEqual({ dirty: FLAG_DIRTY_PAGE }) + }) + + it(`doesn't mark page query as dirty if query text didn't change`, () => { + state = editFooQuery(state, ComponentQueries.foo) + + expect(state.trackedQueries.get(`/foo`)).toEqual({ dirty: 0 }) + // sanity-check (we didn't run or extract /bar) + expect(state.trackedQueries.get(`/bar`)).toEqual({ dirty: FLAG_DIRTY_PAGE }) + }) + + it(`doesn't mark page query as dirty if component has query extraction errors`, () => { + // extract to a valid state first and run to reset initial dirty flag + state = editFooQuery(state, ComponentQueries.foo) + state = reducer( + state, + queryExtractionBabelError( + { componentPath: `/foo.js`, error: new Error(`Babel error`) }, + {} as any + ) + ) + expect(state.trackedQueries.get(`/foo`)?.dirty).toEqual(0) // sanity-check + state = reducer( + state, + queryExtracted({ componentPath: `/foo.js`, query: `` }, {} as any) + ) + expect(state.trackedQueries.get(`/foo`)?.dirty).toEqual(0) + }) + + it(`recovers after component query extraction errors`, () => { + // extract to a valid state first and run to reset initial dirty flag + state = editFooQuery(state, ComponentQueries.foo) + state = reducer( + state, + queryExtractionBabelError( + { componentPath: `/foo.js`, error: new Error(`Babel error`) }, + {} as any + ) + ) + state = reducer( + state, + queryExtracted({ componentPath: `/foo.js`, query: `` }, {} as any) + ) + expect(state.trackedQueries.get(`/foo`)?.dirty).toEqual(0) // sanity-check + state = reducer( + state, + queryExtractedBabelSuccess({ componentPath: `/foo.js` }, {} as any) + ) + state = reducer( + state, + queryExtracted(ComponentQueries.fooEdited, {} as any) + ) + expect(state.trackedQueries.get(`/foo`)?.dirty).toEqual(FLAG_DIRTY_TEXT) + }) + + it(`marks all page queries associated with the component as dirty when query text changes`, () => { + state = editFooQuery(state, ComponentQueries.fooEdited) + + expect(state.trackedQueries.get(`/foo`)).toEqual({ dirty: FLAG_DIRTY_TEXT }) + }) + + it.skip(`marks all static queries associated with this component as dirty`, () => { + // TODO: when we merge static queries and page queries together + }) + + it(`saves query text when it changes`, () => { + state = editFooQuery(state, ComponentQueries.fooEdited) + + expect(state.trackedComponents.get(`/foo.js`)?.query).toEqual( + ComponentQueries.fooEdited.query + ) + }) + + it(`does not change error status of the component (GraphQL)`, () => { + // We call both actions in the real world on extraction failure + state = reducer( + state, + queryExtractionGraphQLError( + { componentPath: `/foo.js`, error: `GraphQL syntax error` }, + {} as any + ) + ) + state = reducer( + state, + queryExtracted({ componentPath: `/foo.js`, query: `` }, {} as any) + ) + expect(state.trackedComponents.get(`/foo.js`)).toMatchObject({ + errors: FLAG_ERROR_EXTRACTION, + query: ``, + }) + }) + + it(`does not change error status of the component (babel)`, () => { + state = reducer( + state, + queryExtractionBabelError( + { componentPath: `/foo.js`, error: new Error(`Babel error`) }, + {} as any + ) + ) + state = reducer( + state, + queryExtracted({ componentPath: `/foo.js`, query: `` }, {} as any) + ) + expect(state.trackedComponents.get(`/foo.js`)).toMatchObject({ + errors: FLAG_ERROR_EXTRACTION, + query: ``, + }) + }) + + function editFooQuery(state, newFoo): QueriesState { + state = reducer(state, queryExtracted(ComponentQueries.foo, {} as any)) + state = runQuery(state, { path: Pages.foo.path }) + expect(state.trackedQueries.get(`/foo`)?.dirty).toEqual(0) // sanity-check + return reducer(state, queryExtracted(newFoo, {} as any)) + } +}) + +describe(`query extraction error`, () => { + it(`marks component with error (babel)`, () => { + state = reducer( + state, + queryExtractionBabelError( + { componentPath: `/foo.js`, error: new Error(`babel error`) }, + {} as any + ) + ) + expect(state.trackedComponents.get(`/foo.js`)).toMatchObject({ + errors: FLAG_ERROR_EXTRACTION, + }) + }) + + it(`marks component with error (GraphQL)`, () => { + state = reducer( + state, + queryExtractionGraphQLError( + { componentPath: `/foo.js`, error: `GraphQL syntax error` }, + {} as any + ) + ) + expect(state.trackedComponents.get(`/foo.js`)).toMatchObject({ + errors: FLAG_ERROR_EXTRACTION, + }) + }) + + it(`resets the error on successful extraction (babel)`, () => { + state = reducer( + state, + queryExtractionBabelError( + { componentPath: `/foo.js`, error: new Error(`babel error`) }, + {} as any + ) + ) + state = reducer( + state, + queryExtractedBabelSuccess({ componentPath: `/foo.js` }, {} as any) + ) + expect(state.trackedComponents.get(`/foo.js`)).toMatchObject({ + errors: 0, + }) + }) + + it(`resets the error on successful extraction (GraphQL)`, () => { + state = reducer( + state, + queryExtractionGraphQLError( + { componentPath: `/foo.js`, error: `GraphQL syntax error` }, + {} as any + ) + ) + state = reducer( + state, + queryExtractedBabelSuccess({ componentPath: `/foo.js` }, {} as any) + ) + expect(state.trackedComponents.get(`/foo.js`)).toMatchObject({ + errors: 0, + }) + }) +}) + +describe(`add page data dependency`, () => { + it(`lets you add a node dependency`, () => { + const action: ICreatePageDependencyAction = { + type: `CREATE_COMPONENT_DEPENDENCY`, + payload: { + path: `/hi/`, + nodeId: `123`, + }, + } + + expect(reducer(undefined, action)).toMatchObject({ + byConnection: new Map(), + byNode: new Map([[`123`, new Set([`/hi/`])]]), + }) + }) + it(`lets you add a node dependency to multiple paths`, () => { + const action: ICreatePageDependencyAction = { + type: `CREATE_COMPONENT_DEPENDENCY`, + payload: { + path: `/hi/`, + nodeId: `1.2.3`, + }, + } + const action2: ICreatePageDependencyAction = { + type: `CREATE_COMPONENT_DEPENDENCY`, + payload: { + path: `/hi2/`, + nodeId: `1.2.3`, + }, + } + const action3: ICreatePageDependencyAction = { + type: `CREATE_COMPONENT_DEPENDENCY`, + payload: { + path: `/blog/`, + nodeId: `1.2.3`, + }, + } + + let state = reducer(undefined, action) + state = reducer(state, action2) + state = reducer(state, action3) + + expect(state).toMatchObject({ + byConnection: new Map(), + byNode: new Map([[`1.2.3`, new Set([`/hi/`, `/hi2/`, `/blog/`])]]), + }) + }) + it(`lets you add a connection dependency`, () => { + const action: ICreatePageDependencyAction = { + type: `CREATE_COMPONENT_DEPENDENCY`, + payload: { + path: `/hi/`, + connection: `Markdown.Remark`, + }, + } + const action2: ICreatePageDependencyAction = { + type: `CREATE_COMPONENT_DEPENDENCY`, + payload: { + path: `/hi2/`, + connection: `Markdown.Remark`, + }, + } + + let state = reducer(undefined, action) + state = reducer(state, action2) + + expect(state).toMatchObject({ + byConnection: new Map([[`Markdown.Remark`, new Set([`/hi/`, `/hi2/`])]]), + byNode: new Map(), + }) + }) + it(`removes duplicate paths`, () => { + const action: ICreatePageDependencyAction = { + type: `CREATE_COMPONENT_DEPENDENCY`, + payload: { + path: `/hi/`, + nodeId: `1`, + connection: `MarkdownRemark`, + }, + } + const action2: ICreatePageDependencyAction = { + type: `CREATE_COMPONENT_DEPENDENCY`, + payload: { + path: `/hi2/`, + nodeId: `1`, + connection: `MarkdownRemark`, + }, + } + + let state = reducer(undefined, action) + // Do it again + state = reducer(state, action) + // Add different action + state = reducer(state, action2) + + expect(state.byConnection.get(`MarkdownRemark`)?.size).toEqual(2) + expect(state.byNode.get(`1`)?.size).toEqual(2) + }) + it(`lets you add both a node and connection in one action`, () => { + const action: ICreatePageDependencyAction = { + type: `CREATE_COMPONENT_DEPENDENCY`, + payload: { + path: `/hi/`, + connection: `MarkdownRemark`, + nodeId: `SuperCoolNode`, + }, + } + + const state = reducer(undefined, action) + + expect(state).toMatchSnapshot() + }) +}) + +describe(`query start`, () => { + beforeEach(() => { + state = createPage(state, Pages.foo) + state = reducer(state, replaceStaticQuery(StaticQueries.q1)) + + const dataDependencies = [ + { path: Pages.foo.path, nodeId: `123` }, + { path: Pages.foo.path, connection: `Test` }, + { path: StaticQueries.q1.id, connection: `Test` }, + { path: StaticQueries.q1.id, nodeId: `123` }, + ] + for (const payload of dataDependencies) { + state = reducer(state, { + type: `CREATE_COMPONENT_DEPENDENCY`, + payload, + }) + } + }) + + it(`has expected baseline`, () => { + expect(state.byNode.get(`123`)).toEqual(new Set([`/foo`, `sq--q1`])) + expect(state.byConnection.get(`Test`)).toEqual(new Set([`/foo`, `sq--q1`])) + }) + + it(`resets data dependencies for page queries`, () => { + state = reducer( + state, + queryStart( + { + componentPath: Pages.foo.componentPath, + path: Pages.foo.path, + isPage: true, + }, + {} as any + ) + ) + expect(state.byNode.get(`123`)?.has(`/foo`)).toEqual(false) + expect(state.byConnection.get(`Test`)?.has(`/foo`)).toEqual(false) + }) + + it(`resets data dependencies for static queries`, () => { + state = reducer( + state, + queryStart( + { + componentPath: StaticQueries.q1.componentPath, + path: StaticQueries.q1.id, + isPage: false, + }, + {} as any + ) + ) + expect(state.byNode.get(`123`)?.has(`sq--q1`)).toEqual(false) + expect(state.byConnection.get(`Test`)?.has(`sq--q1`)).toEqual(false) + }) +}) + +describe(`create node`, () => { + it.todo(`marks page query as dirty when it has this node as a dependency`) + it.todo(`marks static query as dirty when it has this node as a dependency`) + it.todo( + `does not mark page query as dirty when this node is not a query dependency` + ) + it.todo( + `does not mark static query as dirty when this node is not a query dependency` + ) +}) + +describe(`delete node`, () => { + it.todo(`marks page query as dirty when it has this node as a dependency`) + it.todo(`marks static query as dirty when it has this node as a dependency`) + it.todo( + `does not mark page query as dirty when this node is not a query dependency` + ) + it.todo( + `does not mark static query as dirty when this node is not a query dependency` + ) +}) + +describe(`delete cache`, () => { + it(`restores original state`, () => { + const initialState = cloneDeep(state) + state = createPage(state, Pages.foo) + state = createPage(state, Pages.bar) + state = reducer(state, replaceStaticQuery(StaticQueries.q1)) + state = reducer(state, replaceStaticQuery(StaticQueries.q2)) + // sanity check + expect(state).not.toEqual(initialState) + + state = reducer(state, { type: `DELETE_CACHE` }) + expect(state).toEqual(initialState) + }) +}) + +function createPage( + state: QueriesState, + page: Partial, + other: Partial = {} +): QueriesState { + return reducer(state, { + type: `CREATE_PAGE`, + payload: page as IGatsbyPage, + ...other, + }) +} + +function deletePage( + state: QueriesState, + page: Partial +): QueriesState { + return reducer(state, { + type: `DELETE_PAGE`, + payload: page as IGatsbyPage, + }) +} + +function runQuery( + state: QueriesState, + payload: Partial +): QueriesState { + const tmp = startQuery(state, payload) + return finishQuery(tmp, payload) +} + +function startQuery( + state: QueriesState, + payload: Partial +): QueriesState { + return reducer( + state, + queryStart(payload as IQueryStartAction["payload"], {} as any) + ) +} + +function finishQuery( + state: QueriesState, + payload: Partial +): QueriesState { + return reducer( + state, + pageQueryRun(payload as IPageQueryRunAction["payload"], {} as any) + ) +} + +function removeStaticQuery(state: QueriesState, queryId: string): QueriesState { + return reducer(state, { + type: `REMOVE_STATIC_QUERY`, + payload: queryId, + }) +} diff --git a/packages/gatsby/src/redux/reducers/component-data-dependencies.ts b/packages/gatsby/src/redux/reducers/component-data-dependencies.ts deleted file mode 100644 index bfcfcffd2a8c9..0000000000000 --- a/packages/gatsby/src/redux/reducers/component-data-dependencies.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { IGatsbyState, ActionsUnion } from "../types" - -export const componentDataDependenciesReducer = ( - state: IGatsbyState["componentDataDependencies"] = { - nodes: new Map(), - connections: new Map(), - }, - action: ActionsUnion -): IGatsbyState["componentDataDependencies"] => { - switch (action.type) { - case `DELETE_CACHE`: - return { nodes: new Map(), connections: new Map() } - case `CREATE_COMPONENT_DEPENDENCY`: - if (action.payload.path === ``) { - return state - } - - // If this nodeId not set yet. - if (action.payload.nodeId) { - let existingPaths: Set = new Set() - if (state.nodes.has(action.payload.nodeId)) { - existingPaths = state.nodes.get(action.payload.nodeId)! - } - if (!existingPaths.has(action.payload.path)) { - existingPaths.add(action.payload.path) - } - state.nodes.set(action.payload.nodeId, existingPaths) - } - - // If this connection not set yet. - if (action.payload.connection) { - let existingPaths: Set = new Set() - if (state.connections.has(action.payload.connection)) { - existingPaths = state.connections.get(action.payload.connection)! - } - if (!existingPaths.has(action.payload.path)) { - existingPaths.add(action.payload.path) - } - state.connections.set(action.payload.connection, existingPaths) - } - - return state - case `DELETE_COMPONENTS_DEPENDENCIES`: - state.nodes.forEach(val => { - for (const path of action.payload.paths) { - val.delete(path) - } - }) - state.connections.forEach(val => { - for (const path of action.payload.paths) { - val.delete(path) - } - }) - - return state - // Don't delete data dependencies as we're now deleting transformed nodes - // when their parent is changed. WIth the code below as stands, this - // would delete the connection between the page and the transformed - // node which will be recreated after its deleted meaning the query - // won't be re-run. - // case `DELETE_NODE`: - // delete state.nodes[action.payload] - // return state - // case `DELETE_NODES`: - // action.payload.forEach(n => delete state.nodes[n]) - // return state - default: - return state - } -} diff --git a/packages/gatsby/src/redux/reducers/components.ts b/packages/gatsby/src/redux/reducers/components.ts index e3f7fb3e91245..a9a0375c9af99 100644 --- a/packages/gatsby/src/redux/reducers/components.ts +++ b/packages/gatsby/src/redux/reducers/components.ts @@ -1,18 +1,10 @@ import normalize from "normalize-path" -import { interpret, Interpreter } from "xstate" -import _ from "lodash" - -import { - componentMachine, - IContext, - IEvent, - IState, -} from "../machines/page-component" import { IGatsbyState, ActionsUnion } from "../types" -const services = new Map>() let programStatus = `BOOTSTRAPPING` +// TODO: replace usages of this reducer with queries.trackedComponents +// It is here merely for compatibility. export const componentsReducer = ( state: IGatsbyState["components"] = new Map(), action: ActionsUnion @@ -22,115 +14,29 @@ export const componentsReducer = ( return new Map() case `SET_PROGRAM_STATUS`: programStatus = action.payload - if (programStatus === `BOOTSTRAP_QUERY_RUNNING_FINISHED`) { - services.forEach(s => s.send(`BOOTSTRAP_FINISHED`)) - } return state case `CREATE_PAGE`: { action.payload.componentPath = normalize(action.payload.component) // Create XState service. - let service - if (!services.has(action.payload.componentPath)) { - const machine = componentMachine.withContext({ + let component = state.get(action.payload.componentPath) + if (!component) { + component = { componentPath: action.payload.componentPath, - query: state.get(action.payload.componentPath)?.query || ``, - pages: new Set([action.payload.path]), - isInBootstrap: programStatus === `BOOTSTRAPPING`, - }) - service = interpret(machine).start() - // .onTransition(nextState => { - // console.log( - // `component machine value`, - // _.pick(nextState, [`value`, `context`, `event`]) - // ) - // }) - // .start() - services.set(action.payload.componentPath, service) - } else { - service = services.get(action.payload.componentPath) - if (!service.state.context.pages.has(action.payload.path)) { - service.send({ type: `NEW_PAGE_CREATED`, path: action.payload.path }) - } else if (action.contextModified) { - service.send({ - type: `PAGE_CONTEXT_MODIFIED`, - path: action.payload.path, - }) + query: ``, + pages: new Set(), + isInBootstrap: true, } } - - state.set( - action.payload.componentPath, - Object.assign( - { - query: ``, - }, - service.state.context - ) - ) + component.pages.add(action.payload.path) + component.isInBootstrap = programStatus === `BOOTSTRAPPING` + state.set(action.payload.componentPath, component) return state } case `QUERY_EXTRACTED`: { action.payload.componentPath = normalize(action.payload.componentPath) - const service = services.get(action.payload.componentPath)! - - if (service.state.value === `queryExtractionBabelError`) { - // Do nothing until the babel error is fixed. - return state - } - - // Check if the query has changed or not. - if (service.state.context.query === action.payload.query) { - service.send(`QUERY_DID_NOT_CHANGE`) - } else { - service.send({ - type: `QUERY_CHANGED`, - query: action.payload.query, - }) - } - state.set(action.payload.componentPath, { - ...service.state.context, - ...action.payload, - }) - return state - } - case `QUERY_EXTRACTION_BABEL_SUCCESS`: - case `QUERY_EXTRACTION_BABEL_ERROR`: - case `QUERY_EXTRACTION_GRAPHQL_ERROR`: { - let servicesToSendEventTo - if ( - typeof action.payload.componentPath !== `string` && - action.type === `QUERY_EXTRACTION_GRAPHQL_ERROR` - ) { - // if this is globabl query extraction error, send it to all page component services - servicesToSendEventTo = services - } else { - action.payload.componentPath = normalize(action.payload.componentPath) - servicesToSendEventTo = [ - services.get(action.payload.componentPath), - ].filter(Boolean) - } - - servicesToSendEventTo.forEach(service => - service.send({ - type: action.type, - ...action.payload, - }) - ) - - return state - } - case `PAGE_QUERY_RUN`: { - if (action.payload.isPage) { - action.payload.componentPath = normalize(action.payload.componentPath) - const service = services.get(action.payload.componentPath)! - // TODO we want to keep track of whether there's any outstanding queries still - // running as this will mark queries as complete immediately even though - // a page component could have thousands of pages will processing. - // This can be done once we start modeling Pages as well. - service.send({ - type: `QUERIES_COMPLETE`, - }) - } + const component = state.get(action.payload.componentPath)! + component.query = action.payload.query + state.set(action.payload.componentPath, component) return state } case `REMOVE_STATIC_QUERIES_BY_TEMPLATE`: { @@ -139,11 +45,8 @@ export const componentsReducer = ( return state } case `DELETE_PAGE`: { - const service = services.get(normalize(action.payload.component))! - service.send({ - type: `DELETE_PAGE`, - page: action.payload, - }) + const component = state.get(normalize(action.payload.component))! + component.pages.delete(action.payload.path) return state } } diff --git a/packages/gatsby/src/redux/reducers/index.ts b/packages/gatsby/src/redux/reducers/index.ts index b8fdcc55279d8..218898bd67c60 100644 --- a/packages/gatsby/src/redux/reducers/index.ts +++ b/packages/gatsby/src/redux/reducers/index.ts @@ -15,7 +15,6 @@ import { lastActionReducer } from "./last-action" import { jobsV2Reducer } from "./jobsv2" import { pageDataStatsReducer } from "./page-data-stats" import { componentsReducer } from "./components" -import { componentDataDependenciesReducer } from "./component-data-dependencies" import { babelrcReducer } from "./babelrc" import { jobsReducer } from "./jobs" import { nodesByTypeReducer } from "./nodes-by-type" @@ -27,6 +26,7 @@ import { pendingPageDataWritesReducer } from "./pending-page-data-writes" import { schemaCustomizationReducer } from "./schema-customization" import { inferenceMetadataReducer } from "./inference-metadata" import { staticQueriesByTemplateReducer } from "./static-queries-by-template" +import { queriesReducer } from "./queries" /** * @property exports.nodesTouched Set @@ -44,7 +44,6 @@ export { schemaReducer as schema, pagesReducer as pages, statusReducer as status, - componentDataDependenciesReducer as componentDataDependencies, componentsReducer as components, staticQueryComponentsReducer as staticQueryComponents, jobsReducer as jobs, @@ -61,4 +60,5 @@ export { pageDataReducer as pageData, pendingPageDataWritesReducer as pendingPageDataWrites, staticQueriesByTemplateReducer as staticQueriesByTemplate, + queriesReducer as queries, } diff --git a/packages/gatsby/src/redux/reducers/queries.ts b/packages/gatsby/src/redux/reducers/queries.ts new file mode 100644 index 0000000000000..7da92b910dc70 --- /dev/null +++ b/packages/gatsby/src/redux/reducers/queries.ts @@ -0,0 +1,236 @@ +import { + ActionsUnion, + IComponentState, + IGatsbyState, + IQueryState, +} from "../types" + +type QueryId = string // page query path or static query id +type ComponentPath = string +type NodeId = string +type ConnectionName = string + +export const FLAG_DIRTY_PAGE = 0b0001 +export const FLAG_DIRTY_TEXT = 0b0010 +export const FLAG_DIRTY_DATA = 0b0100 + +export const FLAG_ERROR_EXTRACTION = 0b0001 + +const initialState = (): IGatsbyState["queries"] => { + return { + byNode: new Map>(), + byConnection: new Map>(), + trackedQueries: new Map(), + trackedComponents: new Map(), + deletedQueries: new Set(), + } +} + +const initialQueryState = (): IQueryState => { + return { + dirty: -1, // unknown, must be set right after init + } +} + +const initialComponentState = (): IComponentState => { + return { + componentPath: ``, + query: ``, + pages: new Set(), + errors: 0, + // TODO: staticQueries: new Set() + } +} + +/** + * Tracks query dirtiness. Dirty queries are queries that: + * + * - depend on nodes or node collections (via `actions.createPageDependency`) that have changed. + * - have been recently extracted (or their query text has changed) + * - belong to newly created pages (or pages with modified context) + * + * Dirty queries must be re-ran. + */ +export function queriesReducer( + state: IGatsbyState["queries"] = initialState(), + action: ActionsUnion +): IGatsbyState["queries"] { + switch (action.type) { + case `DELETE_CACHE`: + return initialState() + + case `CREATE_PAGE`: { + const { path, componentPath } = action.payload + let query = state.trackedQueries.get(path) + if (!query || action.contextModified) { + query = registerQuery(state, path) + query.dirty = setFlag(query.dirty, FLAG_DIRTY_PAGE) + } + registerComponent(state, componentPath).pages.add(path) + state.deletedQueries.delete(path) + return state + } + case `DELETE_PAGE`: { + // Don't actually remove the page query from trackedQueries, just mark it as "deleted". Why? + // We promote a technique of a consecutive deletePage/createPage calls in onCreatePage hook, + // see https://www.gatsbyjs.com/docs/creating-and-modifying-pages/#pass-context-to-pages + // If we remove a query and then re-add, it will be marked as dirty. + // This is OK for cold cache but with warm cache we will re-run all of those queries (unnecessarily). + // We will reconcile the state after createPages API call and actually delete those queries. + state.deletedQueries.add(action.payload.path) + return state + } + case `API_FINISHED`: { + if (action.payload.apiName !== `createPages`) { + return state + } + for (const queryId of state.deletedQueries) { + for (const component of state.trackedComponents.values()) { + component.pages.delete(queryId) + } + for (const nodeQueries of state.byNode.values()) { + nodeQueries.delete(queryId) + } + for (const connectionQueries of state.byConnection.values()) { + connectionQueries.delete(queryId) + } + state.trackedQueries.delete(queryId) + } + state.deletedQueries.clear() + return state + } + case `QUERY_EXTRACTED`: { + // Note: this action is called even in case of + // extraction error or missing query (with query === ``) + // TODO: use hash instead of a query text + const { componentPath, query } = action.payload + const component = registerComponent(state, componentPath) + if (hasFlag(component.errors, FLAG_ERROR_EXTRACTION)) { + return state + } + if (component.query !== query) { + // Invalidate all pages associated with a component when query text changes + component.pages.forEach(queryId => { + const query = state.trackedQueries.get(queryId) + if (query) { + query.dirty = setFlag(query.dirty, FLAG_DIRTY_TEXT) + } + }) + component.query = query + } + return state + } + case `QUERY_EXTRACTION_GRAPHQL_ERROR`: + case `QUERY_EXTRACTION_BABEL_ERROR`: + case `QUERY_EXTRACTION_BABEL_SUCCESS`: { + const { componentPath } = action.payload + const component = registerComponent(state, componentPath) + const set = action.type !== `QUERY_EXTRACTION_BABEL_SUCCESS` + component.errors = setFlag(component.errors, FLAG_ERROR_EXTRACTION, set) + return state + } + case `REPLACE_STATIC_QUERY`: { + // Only called when static query text has changed, so no need to compare + // TODO: unify the behavior? + const query = registerQuery(state, action.payload.id) + query.dirty = setFlag(query.dirty, FLAG_DIRTY_TEXT) + state.deletedQueries.delete(action.payload.id) + return state + } + case `REMOVE_STATIC_QUERY`: { + state.deletedQueries.add(action.payload) + return state + } + case `CREATE_COMPONENT_DEPENDENCY`: { + const { path: queryId, nodeId, connection } = action.payload + if (nodeId) { + const queryIds = state.byNode.get(nodeId) ?? new Set() + queryIds.add(queryId) + state.byNode.set(nodeId, queryIds) + } + if (connection) { + const queryIds = + state.byConnection.get(connection) ?? new Set() + queryIds.add(queryId) + state.byConnection.set(connection, queryIds) + } + return state + } + case `QUERY_START`: { + // Reset data dependencies as they will be updated when running the query + const { path } = action.payload + state.byNode.forEach(queryIds => { + queryIds.delete(path) + }) + state.byConnection.forEach(queryIds => { + queryIds.delete(path) + }) + return state + } + case `CREATE_NODE`: + case `DELETE_NODE`: { + const node = action.payload + const queriesByNode = state.byNode.get(node.id) ?? [] + const queriesByConnection = + state.byConnection.get(node.internal.type) ?? [] + + queriesByNode.forEach(queryId => { + const query = state.trackedQueries.get(queryId) + if (query) { + query.dirty = setFlag(query.dirty, FLAG_DIRTY_DATA) + } + }) + queriesByConnection.forEach(queryId => { + const query = state.trackedQueries.get(queryId) + if (query) { + query.dirty = setFlag(query.dirty, FLAG_DIRTY_DATA) + } + }) + return state + } + case `PAGE_QUERY_RUN`: { + const { path } = action.payload + const query = registerQuery(state, path) + query.dirty = 0 + return state + } + default: + return state + } +} + +function setFlag(allFlags: number, flag: number, set = true): number { + if (allFlags < 0) { + allFlags = 0 + } + return set ? allFlags | flag : allFlags & ~flag +} + +export function hasFlag(allFlags: number, flag: number): boolean { + return allFlags >= 0 && (allFlags & flag) > 0 +} + +function registerQuery( + state: IGatsbyState["queries"], + queryId: QueryId +): IQueryState { + let query = state.trackedQueries.get(queryId) + if (!query) { + query = initialQueryState() + state.trackedQueries.set(queryId, query) + } + return query +} + +function registerComponent( + state: IGatsbyState["queries"], + componentPath: string +): IComponentState { + let component = state.trackedComponents.get(componentPath) + if (!component) { + component = initialComponentState() + component.componentPath = componentPath + state.trackedComponents.set(componentPath, component) + } + return component +} diff --git a/packages/gatsby/src/redux/types.ts b/packages/gatsby/src/redux/types.ts index 5d9a274ba4db3..2cc3f95f8dbf9 100644 --- a/packages/gatsby/src/redux/types.ts +++ b/packages/gatsby/src/redux/types.ts @@ -152,6 +152,27 @@ export interface IStateProgram extends IProgram { extensions: Array } +export interface IQueryState { + dirty: number +} + +export interface IComponentState { + componentPath: string + query: string + pages: Set + errors: number +} + +export type GatsbyNodeAPI = + | "onPreBoostrap" + | "onPostBoostrap" + | "onCreateWebpackConfig" + | "onCreatePage" + | "sourceNodes" + | "createPagesStatefully" + | "createPages" + | "onPostBuild" + export interface IGatsbyState { program: IStateProgram nodes: GatsbyNodes @@ -168,16 +189,7 @@ export interface IGatsbyState { plugins: [] [key: string]: unknown } - nodeAPIs: Array< - | "onPreBoostrap" - | "onPostBoostrap" - | "onCreateWebpackConfig" - | "onCreatePage" - | "sourceNodes" - | "createPagesStatefully" - | "createPages" - | "onPostBuild" - > + nodeAPIs: Array browserAPIs: Array< | "onRouteUpdate" | "registerServiceWorker" @@ -195,9 +207,12 @@ export interface IGatsbyState { plugins: Record PLUGINS_HASH: Identifier } - componentDataDependencies: { - nodes: Map> - connections: Map> + queries: { + byNode: Map> + byConnection: Map> + trackedQueries: Map + trackedComponents: Map + deletedQueries: Set } components: Map< SystemPath, @@ -264,7 +279,6 @@ export interface IGatsbyState { export interface ICachedReduxState { nodes?: IGatsbyState["nodes"] status: IGatsbyState["status"] - componentDataDependencies: IGatsbyState["componentDataDependencies"] components: IGatsbyState["components"] jobsV2: IGatsbyState["jobsV2"] staticQueryComponents: IGatsbyState["staticQueryComponents"] @@ -273,12 +287,14 @@ export interface ICachedReduxState { pageData: IGatsbyState["pageData"] staticQueriesByTemplate: IGatsbyState["staticQueriesByTemplate"] pendingPageDataWrites: IGatsbyState["pendingPageDataWrites"] + queries: IGatsbyState["queries"] } export type ActionsUnion = | IAddChildNodeToParentNodeAction | IAddFieldToNodeAction | IAddThirdPartySchema + | IApiFinishedAction | ICreateFieldExtension | ICreateNodeAction | ICreatePageAction @@ -287,7 +303,6 @@ export type ActionsUnion = | IDeleteCacheAction | IDeleteNodeAction | IDeleteNodesAction - | IDeleteComponentDependenciesAction | IDeletePageAction | IPageQueryRunAction | IPrintTypeDefinitions @@ -295,6 +310,7 @@ export type ActionsUnion = | IQueryExtractedBabelSuccessAction | IQueryExtractionBabelErrorAction | IQueryExtractionGraphQLErrorAction + | IQueryStartAction | IRemoveStaticQuery | IReplaceComponentQueryAction | IReplaceStaticQueryAction @@ -335,6 +351,13 @@ export type ActionsUnion = | ISetProgramAction | ISetProgramExtensions +export interface IApiFinishedAction { + type: `API_FINISHED` + payload: { + apiName: GatsbyNodeAPI + } +} + interface ISetBabelPluginAction { type: `SET_BABEL_PLUGIN` payload: { @@ -491,6 +514,17 @@ export interface IPageQueryRunAction { type: `PAGE_QUERY_RUN` plugin: IGatsbyPlugin traceId: string | undefined + payload: { + path: string + componentPath: string + isPage: boolean + } +} + +export interface IQueryStartAction { + type: `QUERY_START` + plugin: IGatsbyPlugin + traceId: string | undefined payload: { path: string; componentPath: string; isPage: boolean } } diff --git a/packages/gatsby/src/services/calculate-dirty-queries.ts b/packages/gatsby/src/services/calculate-dirty-queries.ts index 4309074c0fab4..00b57f3c76e18 100644 --- a/packages/gatsby/src/services/calculate-dirty-queries.ts +++ b/packages/gatsby/src/services/calculate-dirty-queries.ts @@ -1,24 +1,15 @@ -import { - calcInitialDirtyQueryIds, - calcDirtyQueryIds, - groupQueryIds, -} from "../query" +import { calcDirtyQueryIds, groupQueryIds } from "../query" import { IGroupedQueryIds } from "./" import { IQueryRunningContext } from "../state-machines/query-running/types" import { assertStore } from "../utils/assert-store" export async function calculateDirtyQueries({ store, - firstRun, }: Partial): Promise<{ queryIds: IGroupedQueryIds }> { assertStore(store) - const state = store.getState() - - const queryIds = firstRun - ? calcInitialDirtyQueryIds(state) - : calcDirtyQueryIds(state) + const queryIds = calcDirtyQueryIds(state) return { queryIds: groupQueryIds(queryIds) } } diff --git a/packages/gatsby/src/services/create-pages.ts b/packages/gatsby/src/services/create-pages.ts index 714d1a0b27e59..2ea41f19740be 100644 --- a/packages/gatsby/src/services/create-pages.ts +++ b/packages/gatsby/src/services/create-pages.ts @@ -3,6 +3,7 @@ import apiRunnerNode from "../utils/api-runner-node" import { IDataLayerContext } from "../state-machines/data-layer/types" import { assertStore } from "../utils/assert-store" import { IGatsbyPage } from "../redux/types" +import { boundActionCreators } from "../redux/actions" import { deleteUntouchedPages, findChangedPages } from "../utils/changed-pages" export async function createPages({ @@ -74,6 +75,8 @@ export async function createPages({ ) tim.end() + boundActionCreators.apiFinished({ apiName: `createPages` }) + return { changedPages, deletedPages, diff --git a/packages/gatsby/src/utils/changed-pages.ts b/packages/gatsby/src/utils/changed-pages.ts index 71d63f440a86e..8d333d5c0dc04 100644 --- a/packages/gatsby/src/utils/changed-pages.ts +++ b/packages/gatsby/src/utils/changed-pages.ts @@ -1,5 +1,5 @@ import { boundActionCreators } from "../redux/actions" -const { deletePage, deleteComponentsDependencies } = boundActionCreators +const { deletePage } = boundActionCreators import { isEqualWith, IsEqualCustomizer } from "lodash" import { IGatsbyPage } from "../redux/types" @@ -17,7 +17,6 @@ export function deleteUntouchedPages( page.updatedAt < timeBeforeApisRan && page.path !== `/404.html` ) { - deleteComponentsDependencies([page.path]) deletePage(page) deletedPages.push(page.path, `/page-data${page.path}`) }