Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(gatsby): detect node mutations (enabled by flag or env var) #34006

Merged
merged 12 commits into from Dec 8, 2021
3 changes: 2 additions & 1 deletion packages/gatsby/src/redux/actions/public.js
Expand Up @@ -28,6 +28,7 @@ const normalizePath = require(`../../utils/normalize-path`).default
import { createJobV2FromInternalJob } from "./internal"
import { maybeSendJobToMainProcess } from "../../utils/jobs/worker-messaging"
import { reportOnce } from "../../utils/report-once"
import { wrapNode } from "../../utils/detect-node-mutations"

const isNotTestEnv = process.env.NODE_ENV !== `test`
const isTestEnv = process.env.NODE_ENV === `test`
Expand Down Expand Up @@ -868,7 +869,7 @@ actions.createNode =

const { payload: node, traceId, parentSpan } = createNodeAction
return apiRunnerNode(`onCreateNode`, {
node,
node: wrapNode(node),
traceId,
parentSpan,
traceTags: { nodeId: node.id, nodeType: node.internal.type },
Expand Down
14 changes: 9 additions & 5 deletions packages/gatsby/src/schema/node-model.js
Expand Up @@ -22,6 +22,7 @@ import {
} from "../datastore"
import { GatsbyIterable, isIterable } from "../datastore/common/iterable"
import { reportOnce } from "../utils/report-once"
import { wrapNode, wrapNodes } from "../utils/detect-node-mutations"

type TypeOrTypeName = string | GraphQLOutputType

Expand Down Expand Up @@ -149,7 +150,7 @@ class LocalNodeModel {
this.trackInlineObjectsInRootNode(node)
}

return this.trackPageDependencies(result, pageDependencies)
return wrapNode(this.trackPageDependencies(result, pageDependencies))
}

/**
Expand Down Expand Up @@ -180,7 +181,7 @@ class LocalNodeModel {
result.forEach(node => this.trackInlineObjectsInRootNode(node))
}

return this.trackPageDependencies(result, pageDependencies)
return wrapNodes(this.trackPageDependencies(result, pageDependencies))
}

/**
Expand Down Expand Up @@ -221,7 +222,7 @@ class LocalNodeModel {
typeof type === `string` ? type : type.name
}

return this.trackPageDependencies(result, pageDependencies)
return wrapNodes(this.trackPageDependencies(result, pageDependencies))
}

/**
Expand Down Expand Up @@ -346,7 +347,10 @@ class LocalNodeModel {
pageDependencies.connectionType = gqlType.name
}
this.trackPageDependencies(result.entries, pageDependencies)
return result
return {
entries: wrapNodes(result.entries),
totalCount: result.totalCount,
}
}

/**
Expand Down Expand Up @@ -383,7 +387,7 @@ class LocalNodeModel {
// the query whenever any node of this type changes.
pageDependencies.connectionType = gqlType.name
}
return this.trackPageDependencies(first, pageDependencies)
return wrapNode(this.trackPageDependencies(first, pageDependencies))
}

prepareNodes(type, queryFields, fieldsToResolve) {
Expand Down
5 changes: 5 additions & 0 deletions packages/gatsby/src/services/initialize.ts
Expand Up @@ -20,6 +20,7 @@ import { IGatsbyState, IStateProgram } from "../redux/types"
import { IBuildContext } from "./types"
import { detectLmdbStore } from "../datastore"
import { loadConfigAndPlugins } from "../bootstrap/load-config-and-plugins"
import { enableNodeMutationsDetection } from "../utils/detect-node-mutations"

interface IPluginResolution {
resolve: string
Expand Down Expand Up @@ -161,6 +162,10 @@ export async function initialize({
}
const lmdbStoreIsUsed = detectLmdbStore()

if (process.env.GATSBY_EXPERIMENTAL_DETECT_NODE_MUTATIONS) {
enableNodeMutationsDetection()
}

if (config && config.polyfill) {
reporter.warn(
`Support for custom Promise polyfills has been removed in Gatsby v2. We only support Babel 7's new automatic polyfilling behavior.`
Expand Down
40 changes: 36 additions & 4 deletions packages/gatsby/src/utils/api-runner-node.js
Expand Up @@ -30,6 +30,7 @@ const { requireGatsbyPlugin } = require(`./require-gatsby-plugin`)
const { getNonGatsbyCodeFrameFormatted } = require(`./stack-trace-utils`)
const { trackBuildError, decorateEvent } = require(`gatsby-telemetry`)
import errorParser from "./api-runner-error-parser"
import { wrapNode, wrapNodes } from "./detect-node-mutations"

if (!process.env.BLUEBIRD_DEBUG && !process.env.BLUEBIRD_LONG_STACK_TRACES) {
// Unless specified - disable longStackTraces
Expand All @@ -40,6 +41,21 @@ if (!process.env.BLUEBIRD_DEBUG && !process.env.BLUEBIRD_LONG_STACK_TRACES) {
Promise.config({ longStackTraces: false })
}

const nodeMutationsWrappers = {
getNode(id) {
return wrapNode(getNode(id))
},
getNodes() {
return wrapNodes(getNodes())
},
getNodesByType(type) {
return wrapNodes(getNodesByType(type))
},
getNodeAndSavePathDependency(id) {
return wrapNode(getNodeAndSavePathDependency(id))
},
}

// Bind action creators per plugin so we can auto-add
// metadata to actions they create.
const boundPluginActionCreators = {}
Expand Down Expand Up @@ -372,6 +388,14 @@ const runAPI = async (plugin, api, args, activity) => {
runningActivities.forEach(activity => activity.end())
}

const shouldDetectNodeMutations = [
`sourceNodes`,
`onCreateNode`,
`createResolvers`,
`createSchemaCustomization`,
`setFieldsOnGraphQLNodeType`,
].includes(api)

const apiCallArgs = [
{
...args,
Expand All @@ -383,11 +407,19 @@ const runAPI = async (plugin, api, args, activity) => {
store,
emitter,
getCache,
getNodes,
getNode,
getNodesByType,
getNodes: shouldDetectNodeMutations
? nodeMutationsWrappers.getNodes
: getNodes,
getNode: shouldDetectNodeMutations
? nodeMutationsWrappers.getNode
: getNode,
getNodesByType: shouldDetectNodeMutations
? nodeMutationsWrappers.getNodesByType
: getNodesByType,
reporter: extendedLocalReporter,
getNodeAndSavePathDependency,
getNodeAndSavePathDependency: shouldDetectNodeMutations
? nodeMutationsWrappers.getNodeAndSavePathDependency
: getNodeAndSavePathDependency,
cache,
createNodeId: namespacedCreateNodeId,
createContentDigest,
Expand Down
159 changes: 159 additions & 0 deletions packages/gatsby/src/utils/detect-node-mutations.ts
@@ -0,0 +1,159 @@
import reporter from "gatsby-cli/lib/reporter"
import { getNonGatsbyCodeFrameFormatted } from "./stack-trace-utils"
import type { IGatsbyNode } from "../redux/types"

const reported = new Set<string>()

const genericProxy = createProxyHandler()
const nodeInternalProxy = createProxyHandler({
onGet(key, value) {
if (key === `fieldOwners` || key === `content`) {
// all allowed in here
return value
}
return undefined
},
onSet(target, key, value) {
if (key === `fieldOwners` || key === `content`) {
target[key] = value
return true
}
return undefined
},
})

const nodeProxy = createProxyHandler({
onGet(key, value) {
if (key === `internal`) {
return memoizedProxy(value, nodeInternalProxy)
} else if (
key === `__gatsby_resolved` ||
key === `fields` ||
key === `children`
) {
// all allowed in here
return value
}
return undefined
},
onSet(target, key, value) {
if (key === `__gatsby_resolved` || key === `fields` || key === `children`) {
target[key] = value
return true
}
return undefined
},
})

/**
* Every time we create proxy for object, we store it in WeakMap,
* so that we reuse it for that object instead of creating new Proxy.
* This also ensures reference equality: `memoizedProxy(obj) === memoizedProxy(obj)`.
* If we didn't reuse already created proxy above comparison would return false.
*/
const referenceMap = new WeakMap<any, any>()
function memoizedProxy<T>(target: T, handler: ProxyHandler<any>): T {
const alreadyWrapped = referenceMap.get(target)
if (alreadyWrapped) {
return alreadyWrapped
} else {
const wrapped = new Proxy(target, handler)
referenceMap.set(target, wrapped)
return wrapped
}
}

function createProxyHandler({
onGet,
onSet,
}: {
onGet?: (key: string | symbol, value: any) => any
onSet?: (target: any, key: string | symbol, value: any) => boolean | undefined
} = {}): ProxyHandler<any> {
function set(target, key, value): boolean {
if (onSet) {
const result = onSet(target, key, value)
if (result !== undefined) {
return result
}
}

const error = new Error(`Stack trace:`)
Error.captureStackTrace(error, set)

if (error.stack && !reported.has(error.stack)) {
reported.add(error.stack)
const codeFrame = getNonGatsbyCodeFrameFormatted({
stack: error.stack,
})
reporter.warn(
`Node mutation detected\n\n${
codeFrame ? `${codeFrame}\n\n` : ``
}${error.stack.replace(/^$Error:?\s*/, ``)}`
)
}
return true
}

function get(target, key): any {
const value = target[key]

if (onGet) {
const result = onGet(key, value)
if (result !== undefined) {
return result
}
}

const fieldDescriptor = Object.getOwnPropertyDescriptor(target, key)
if (fieldDescriptor && !fieldDescriptor.writable) {
// this is to prevent errors like:
// ```
// TypeError: 'get' on proxy: property 'constants' is a read - only and
// non - configurable data property on the proxy target but the proxy
// did not return its actual value
// (expected '[object Object]' but got '[object Object]')
// ```
return value
}

if (typeof value === `object` && value !== null) {
return memoizedProxy(value, genericProxy)
}

return value
}

return {
get,
set,
}
}

let shouldWrapNodesInProxies =
!!process.env.GATSBY_EXPERIMENTAL_DETECT_NODE_MUTATIONS
export function enableNodeMutationsDetection(): void {
shouldWrapNodesInProxies = true

reporter.warn(
`Node mutation detection is enabled. Remember to disable it after you are finished with diagnostic as it will cause build performance degradation.`
)
}

export function wrapNode<T extends IGatsbyNode | undefined>(node: T): T {
if (node && shouldWrapNodesInProxies) {
return memoizedProxy(node, nodeProxy)
} else {
return node
}
}

export function wrapNodes<T extends Array<IGatsbyNode> | undefined>(
nodes: T
): T {
if (nodes && shouldWrapNodesInProxies && nodes.length > 0) {
return nodes.map(node => memoizedProxy(node, nodeProxy)) as T
} else {
return nodes
}
}
10 changes: 10 additions & 0 deletions packages/gatsby/src/utils/flags.ts
Expand Up @@ -226,6 +226,16 @@ const activeFlags: Array<IFlag> = [
},
requires: `Requires Node v14.10 or above.`,
},
{
name: `DETECT_NODE_MUTATIONS`,
env: `GATSBY_EXPERIMENTAL_DETECT_NODE_MUTATIONS`,
command: `all`,
telemetryId: `DetectNodeMutations`,
// TODO: description
description: ``,
experimental: false,
testFitness: (): fitnessEnum => true,
},
]

export default activeFlags
23 changes: 19 additions & 4 deletions packages/gatsby/src/utils/stack-trace-utils.ts
Expand Up @@ -50,8 +50,18 @@ interface ICodeFrame {

export const getNonGatsbyCodeFrame = ({
highlightCode = true,
stack,
}: {
highlightCode?: boolean
stack?: string
} = {}): null | ICodeFrame => {
const callSite = getNonGatsbyCallSite()
let callSite
if (stack) {
callSite = stackTrace.parse({ stack, name: ``, message: `` })[0]
} else {
callSite = getNonGatsbyCallSite()
}

if (!callSite) {
return null
}
Expand Down Expand Up @@ -80,11 +90,16 @@ export const getNonGatsbyCodeFrame = ({
}
}

export const getNonGatsbyCodeFrameFormatted = ({ highlightCode = true } = {}):
| null
| string => {
export const getNonGatsbyCodeFrameFormatted = ({
highlightCode = true,
stack,
}: {
highlightCode?: boolean
stack?: string
} = {}): null | string => {
const possibleCodeFrame = getNonGatsbyCodeFrame({
highlightCode,
stack,
})

if (!possibleCodeFrame) {
Expand Down