Re: [csswg-drafts] [css-values] Short-circuit if() evaluation (#11500)

Had a big discussion with @andruud about this offline, and pulled in a few related issues (notably, <https://github.com/w3c/csswg-drafts/issues/11144>).

Here are a few of the issues we wanted to solve, in no particular order:

* `var(var(--foo))` should work. It fails to parse today in all engines, because `var(--foo)` isn't a `<custom-ident>`, like `var()`'s grammar requires.
    * Today, `var(--foo, var(--fallback))` works, because the fallback grammar is `<declaration-value>`, so the `var(--fallback)` is uninterpreted. This also means we don't actually *resolve* the `var(--fallback)` unless it's needed, which is nice, as it means you don't get cycles from unused fallback. This is actually somewhat unintentional, tho!
* `if(test1(): cheap(); test2(): expensive())` should work and not run the `expensive()` calculation unless its `test2()` is actually reached and returns true. This means that if() clause bodies should have the same "not evaluated unless needed" behavior that var() fallbacks have. But this probably requires us to know the boundaries of the test and body at parse time.
* Using a var() as an argument to a custom function (`--foo(1, var(--arg), 2)`) is a little fraught in the current spec - the `var()` *might* expand into a comma-separated list and produce multiple arguments (either changing the behavior of the function, or making the call invalid due to too many args). If you're taking a value that could theoretically be comma-separated, you should defensively wrap any var()s in args in `{}`, like `--foo(1, {var(--arg)}, 2)`, as that ensures the var() expands to only a single arg no matter what. This is something that authors will probably usually forget!
    * So, ideally, we know argument boundaries before substitution. But we still want to *allow* substitution to produce multiple args, like it can do in normal functions (like `linear-gradient(to left, var(--stops))`).

---------

So, new proposal that solves this issue, #11144, and the "authors will forget to use `{}`" issue all at once. Anders is reasonably satisfied with it.

1. Substitution functions have *two* grammars, an early and late. The early grammar is applied *before* substituting anything in their arglist, and late grammar is applied *after*.
2. Early grammars are, by convention, *incredibly* loose. For most substitution functions, the early grammar will just be `<declaration-value>#`, possibly with a `{N}` restriction on that. `if()` is a little more complex, as `[<declaration-value> : <declaration-value? ; ]+`.
3. The early grammar dictates the overall function call structure - it separates tokens into arguments, etc. This means that a `var()` whose substitution value includes commas will *not* expand into multiple arguments when passed to a substitution function; we already know the boundaries of the argument it's expanding into. (That is, `--list: 2, 3; width: --fn(1, var(--list), 4);` is identical to `width: --fn(1, {2, 3}, 4);`.)
4. Substitution functions define when exactly their args (determined from the early grammar) are evaluated, triggering substitution. For example, `var(name, fallback)` would immediately evaluate its first arg, the name, but not evaluate the fallback unless it's needed due to substitution failure. `if(t1: v1; t2: v2)` would immediately evaluate t1, only evaluate v1 if t1 passed, then evaluate t2 only if t1 failed, and only evaluate v2 if t2 passed, etc.
5. We add a syntax for "spreading" a substitution function into another substitution functions arguments, akin to JS's `...` operator. Assume it's provisionally spelled `...`, identical to JS. This causes the following substitution function to be evaluated *immediately*, so the early grammar sees the results. (That is, `--list: 2, 3; width: --fn(1, ...var(--list), 4);` is identical to `width: --fn(1, 2, 3, 4);`.)

This has the downside that using a substitution function that evaluates to a comma-separated list acts differently depending on whether it's inside a normal function or another substitution function. (As said above, `linear-gradient(var(--args))` needs to continue to work, but `--my-gradient(var(--args))` wouldn't do the same; you'd need to write `--my-gradient(...var(--args))`. I think this is *required* to achieve the goals stated in this post, tho (both allowing `var(var(...))` and allowing late evaluation of `if()` args).  It's also true that the contexts are somewhat inherently different: non-substitution functions *never* take a comma-separated argument and *never* need `{...}` syntax, while substitution functions *can* but *usually won't*.  So, this is a bullet I'm willing to bite.

There are a few other downstream consequences, which I think are okay. For example, `--whole-clause: t1: v1; width: if(var(--whole-clause); t2: v2)` isn't allowed - it's invalid per early grammar since there's no `:`.  You can either write `--t1: t1; --v1: v1;  width: if(var(--t1): var(--v1); t2: v2);` (separating the test and argument into separate variables), or write `--whole-clause: t1: v1; if(...var(--whole-clause); t2: v2)` (subbing it in early, but losing the potential for lazy evaluation of `v2`). You've *already* lost that ability anyway, since v2 was already evaluated as part of resolving the `--whole-clause` custom property, so nothing important is lost.

-- 
GitHub Notification of comment by tabatkins
Please view or discuss this issue at https://github.com/w3c/csswg-drafts/issues/11500#issuecomment-2657762184 using your GitHub account


-- 
Sent via github-notify-ml as configured in https://github.com/w3c/github-notify-ml-config

Received on Thursday, 13 February 2025 21:37:14 UTC