Skip to content

[css-values] Short-circuit if() evaluation #11500

Closed

Description

@andruud

In a world where huge trees of custom function calls and inline if()s need to be evaluated, it would be nice if we could stop evaluating once a matching condition is found:

 if(cheap-test1(): 10px;
    cheap-test2(): 42px;
    else: --expensive())

In the above, we don't want to evaluate (substitute) --expensive() if an earlier branch will do.

Relatedly, a var() function also has up to two branches: the main value produced by the custom property being referenced, and optionally the fallback value. As specified currently, we can not short-circuit this "evaluation", since we need to look for cycles in the fallback value:

--valid: 1px;
--x: var(--valid, var(--y)); /* Cycle; IACVT */
--y: var(--valid, var(--x)); /* Cycle; IACVT */

--x and --y are in a cycle above, even though the fallback paths are not taken. By default, I'd expect this to extend to the branches not taken for if() as well, otherwise we'd miss the cycle in a case like:

--x: if(test-which-is-true(): 10px;
        else: var(--y));
--y: var(--x);

So:

  • Should we specify this short-circuiting behavior for if()?
  • Should we avoid looking for cycles in the fallback part of var() when it's not taken? (Requires compat investigation.)
  • Should we generally avoid looking for cycles in a branch not taken during any substitution? (E.g. attr()).

Activity

  1. moved this to FTF agenda items in CSSWG January 2025 meetingon Jan 22, 2025
  2. moved this from FTF agenda items to Friday morning in CSSWG January 2025 meetingon Jan 24, 2025
  3. andruud commented on Jan 27, 2025

    @andruud
    MemberAuthor

    Should we generally avoid looking for cycles in a branch not taken during any substitution? (E.g. attr()).

    This would extend to custom functions as well; none of the following functions are in a cycle:

    @function --x() {
      @media (false) {
        result: --x();
      }
      result: 1px;
    }
    
    @function --y() {
      @if (false) { /* If added in the future */
        result: --y();
      }
      result: 1px;
    }
    
    @function --z() {
      --unused: var(--unused);
      result: 1px;
    }
    

    This probably does place --w() in a cycle, however, since we can't leave the locals unevaluated due to dynamic scoping (#10954):

    @function --w() {
      --unused: --w();
      result: 1px;
    }
    

    EDIT: Added --w() example.

  4. tabatkins commented on Jan 31, 2025

    @tabatkins
    Member

    Yes, --x() and --y() should be non-cyclic; --z() has a cycle inside of itself (--unused is cyclic) but the function itself isn't cyclic; and --w() is cyclic.

  5. moved this from Friday morning to Friday afternoon in CSSWG January 2025 meetingon Jan 31, 2025
  6. added a commit that references this issue on Feb 7, 2025
  7. tabatkins commented on Feb 13, 2025

    @tabatkins
    Member

    Had a big discussion with @andruud about this offline, and pulled in a few related issues (notably, #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 v1). You've already lost that ability anyway, since v1 was already evaluated as part of resolving the --whole-clause custom property, so nothing important is lost.

  8. 30 remaining items

  9. tabatkins commented on May 6, 2025

    @tabatkins
    Member

    Neither are "informative", they're both normative grammars used for parsing, just at different points in the processing cycle. It is definitely the case that, for example, authors need to know that var() expects to see var( <custom-ident>, <declaration-value? ); seeing var( <declaration-value>, <declaration-value>? ) doesn't tell them anything. The fact that var(1) satisfies the second grammar is irrelevant for the author; it's still invalid, but now it's invalid at a different time (computed-value time rather than parse time) than it used to be. The distinction rarely matters for authors.

  10. deleted a comment from on Aug 18, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions