Think of it as if everything you pass to an non-standard eval (NSE) function will be evaluated in the data frame's environment. If there's no variable there by the specified name, the function will look in parent environment until it finds it or is forced to error out. This is how NSE has always worked in R (see ?with), and is in keeping with the lexical scoping of environments.
The new tidy eval escapes are really built for adjusting where that evaluation looks. Most of the time you only need .data if you've got another variable in a nested environment (usually belonging to a function) of the same name (likely a modified copy to be compared) and you need to reach up to the version in the NSE environment or to limit scoping to the NSE environment, should it matter. !! is mostly needed when writing new tidy eval functions, but can also be used to escape the NSE environment and look upwards for a variable.
Everyone could always use either .data or !!, but frankly it's a lot of work and a waste of keystrokes for most interactive usage, just like prefixing every function with some_package::. The likelihood of an inadvertent name clash when everything is stored in data frames (as it should be!) that doesn't error out due to type/length is negligible. That's not the case for programmatic work (packages, the odd very-flexible Shiny app), where tools for managing scope are important.
Ultimately, the criticism is correct: what filter(df, x == y) returns depends on what df contains and possibly what the enclosing environments contain. But that's true of any call in a scoped environment (including .GlobalEnv; run pryr::parenvs(all = TRUE)). In fact, the call already depends on scoping by naming the data frame df, which is also the name of a function in stats. Even in base R, we can still escape the function so we can write
df(1, 1, 1)
#> [1] 0.1591549
# here `c(df, df)` is a list of two copies of a function
do.call(mapply, c(df, df))
#> Error in dots[[1L]][[1L]]: object of type 'closure' is not subsettable
df <- data.frame(x = 1:3,
df1 = 1:3,
df2 = 1:3)
# here it's two copies of a data frame
do.call(mapply, c(df, df))
#> Error in match.fun(FUN): argument "FUN" is missing, with no default
# here it's escaped so it's a list of a function and a data frame
do.call(mapply, c(stats::df, df))
#> [1] 0.15915494 0.11111111 0.06891611
Scoping is everywhere, but almost always works without incident:
library(dplyr)
pryr::parenvs()
#> label name
#> 1 <environment: 0x7ff212060b80> ""
#> 2 <environment: R_GlobalEnv> ""
with(data.frame(x = 1), pryr::parenvs())
#> label name
#> 1 <environment: 0x7ff2129d5950> ""
#> 2 <environment: 0x7ff212060b80> ""
#> 3 <environment: R_GlobalEnv> ""
data.frame() %>% {pryr::parenvs()}
#> label name
#> 1 <environment: 0x7ff2108c5b78> ""
#> 2 <environment: 0x7ff212060b80> ""
#> 3 <environment: R_GlobalEnv> ""
data.frame(x = 1) %>%
mutate(x = list(pryr::parenvs())) %>%
purrr::pluck('x', 1)
#> label name
#> 1 <environment: 0x7ff210926710> ""
#> 2 <environment: 0x7ff210911ce8> ""
#> 3 <environment: 0x7ff2109076a8> ""
#> 4 <environment: 0x7ff21099e8d0> ""
#> 5 <environment: 0x7ff212060b80> ""
#> 6 <environment: R_GlobalEnv> ""
In fact, all those stacks have one extra environment compared to calling them interactively, because I evaluated everything with reprex, which evaluated everything in a clean environment.
Should new useRs be taught how tidy eval is an extension of lexical scoping? Well, no, not at first; they should be taught to stick their variable names in the place they need so they can analyze their data. When they run into a case where it actually matters, they can learn moreāor just pick a new variable name and go on in blissful ignorance a while longer.