RFC: New Prototype `Neos.Fusion:Switch`

@Marc and I looked at some ways of conditionally rendering things in Fusion and came to the conclusion that there is room for improvement.

Current ways

Neos.Fusion:Case

The classic Case is probably the most used way of conditionally deciding which things should be rendered:

Neos.Fusion:Case {
	foo {
		condition = ${q(node).property('thing') === "foo"}
		renderer = "oof"
	}
	bar {
		condition = ${q(node).property('thing') === "bar"}
		renderer = "rab"
	}
	default {
		condition = true
		renderer = null
	}	
}

The case implementation is not straight forward. Neither the fusion side nor the actual php implementation. Beginning with the incoherent name of case which name contradicts its counterpart switch in almost all programming languages. Also the explicitly set condition and rendering properties inside the implicit Neos.Fusion:Matcher are not ideal.

Neos.Fusion:Match

For less complex and string-only conditions the Neos.Fusion:Match can be used:

myValue = Neos.Fusion:Match {
        @subject = 'hello'
        @default = 'World?'
        hello = 'Hello World'
        bye = 'Goodbye world'
}

@if

Using @if to “switch” what should be renderer is most common in AFX:

renderer = afx`
	<a href="/logout" @if.isLoggedIn={user}>Logout</a>
	<a href="/login" @if.isNotLoggedIn={!user}>Login</a>
`

Replacing: Neos.Fusion:Case

By using common naming like switch and case it is easier to understand what the parts do. Especially for newcomers.

The assignment of a property instead of the implicit Neos.Fusion:Matcher makes the code slimmer and more inline with other commonly used prototypes while keeping the functionality of the Neos.Fusion:Case.

Instead of using @if the new @case and the somewhat new @default properties are used. @case can be also be named to mimic multiple cases like in PHP where the condition is matched if any of the provided @cases is true.

Also the default case is now predefined with the @default property

Neos.Fusion:Switch {
    someCondition = Neos.Fusion:Renderer {
        @case = ${q(node).is('[instanceof MyNamespace:My.Special.SuperType]')}
        type = 'MyNamespace:My.Special.Type'
    }

    otherCondition = Neos.Fusion:Value {
        @position = 'start'
        @case.isSpecial = ${q(documentNode).property('layout') == 'special'}
        @case.isSomethinElse = ${q(documentNode).property('layout') == 'else'}
        renderer = ${'<marquee>' + q(node).property('content') + '</marquee>'}
    }

    @default = Neos.Fusion:Renderer {
        path = '/myPath'
    }
}
4 Likes

Neos.Fusion:Switch is indeed a much better name then Neos.Fusion:Case however the suggested solution with meta @case is not a real improvement

How about simply renaming Neos.Fusion:Case to Neos.Fusion:Switch (with backwards compat for a while)

1 Like

I think this is an improvement for the following reasons:

The implicit use of the prototype Neos.Fusion:Matcher in the Case has a few downsides:

  • the php implementation is a bit hacky with the MATCH_NORESULT pattern
    • this seems a bit against how fusion objects are supposed to work as the Neos.Fusion:Matcher is not usable standalone.
    • not all fusion features like @process are working and cause odd (understandable) glitches
  • using the matcher as layer in between was probably only done to allow the use of this in the condition (at least there is a test for that)
    • using this is not that important in todays Fusion world with props and private.
    • note that our proposed @case will not have access to this, but that should be understandable from the scope in other languages as the condition is separate.
  • for its main use-case, as we perceive it, mostly the renderer option is used instead of type and rendererPath. That leads to additional boilerplate code.
  • the naming of the Neos.Fusion:Matcher has been there first but the introduction of the Neos.Fusion:Match conflicts now with it and leads to conFusion.
  • the default case must be implemented via condition = true, which is a bit verbose as well.
  • its basically impossible to use the case and matcher in afx due to its structure: Neos.Fusion:Case in afx or should we have a Neos.Fusion:If?
  • The Neos.Fusion:Renderer should in my opinion be split up into two more explicit prototypes (discussion). The Neos.Fusion:Matcher inheriting from it makes this a bit hard to completely get rid of the renderer, renderPath and type functionality in one object. So in my plan it would fit perfectly together to deprecate both and provide better dedicated replacements.

So in conclusion:

On long term we could replace the CaseImplementation and MatcherImplementation with one SwitchImplementation, which is easier to understand, has a more fitting naming and better usability due to less boilerplate code required by the fusion integrator.

2 Likes

Thanks for the explanation. Makes 100% sense to me now. Removing the invisible Matcher prototype is indeed a huge plus.

so +1 from my side

2 Likes

Thinking further the approach should work well with afx which was always a hassle with Neos.Fusion:Case

<Neos.Fusion:Switch>
    <Neos.Fusion:Renderer 
        @path="someCondition" 
        @case={q(node).is('[instanceof MyNamespace:My.Special.SuperType]')}
        type="MyNamespace:My.Special.Type"
    />

    <Neos.Fusion:Value
        @path="otherCondition"
        @position="start"
        @case.isSpecial={q(documentNode).property('layout') == 'special'}
        @case.isSomethinElse={q(documentNode).property('layout') == 'else'}
        renderer={'<marquee>' + q(node).property('content') + '</marquee>'}
    />

    <Neos.Fusion:Renderer
        @path="@default"
        path="/myPath"
    />
>

only downside is that one has to specify @path for each key

To me the @case feels a bit awkward to be “available” in any prototype within this Switch structure.
Or is there special logic to prevent it being inherited? I.e. can I specify any prototype with a @case and depending on the structure it is used?

Why doesn’t it have access to this? Afaik @if does and it feels very similar to what is planned here – in general it’s a bit like @if with Switch being a prototype rendering the first “valid” child, so there might be an argument to just use @if to not introduce a lot of special properties:
is there any other special behavior planned for @case and @default that makes them different?
(Compare this rough makeshift switch: FusionPen: Build, Test and Discover Fusion Code for Neos CMS)

(It’s a bit like the react-router <Switch> only rendering the first matching <Route> and stopping after that as opposed to omitting the <Switch> and every matched child is rendered)

1 Like

Availability

The @case will be as available or inheritable as the @position currently is. It will be used inside the Neos.Fusion:Switch otherwise it is just a meta property hanging around.

So we are not introducing something new in this aspect.

Switches

The idea behind the RFC is to mimic existing switch statements and bringing established wording and functionality to fusion.

Some examples:

// PHP
switch ($i) {
    case 0:
        echo "Zero";
        break;
    case 1:
        echo "one";
        break;
    case 2:
  	case 3:
    	echo "two or three";
    	break;
    default:
    	echo "not zero or one or two or three";
}
// Go
switch os_name {
	case "darwin":
		fmt.Println("OS X.")
	case "linux":
		fmt.Println("Linux.")
	default:
		fmt.Printf("%s.\n", os)
}

Some languages have switches with implicit breaks, cannot fall through or they must be exhausted but the general idea is always similar.

this or that

If we look at this PHP example:

// PHP
$i = 0;
switch ($i) {
    case $number:
	    $number = 0;
        echo "Zero";
        break;
  	default:
		echo "not zero or one or two or three";
}

it is clear that the case will have no access to its inner block. So when the case gets evaluated no $number variable is present.

In the same way the @case should not have access to this of its surrounding block as @if would.

Another way of thinking about it would like this:

Vendor.Package:Thing { 
    val = true
    @if.test = ${this.val}
        
	renderer = "asdf"
}

Here the Vendor.Package:Thing itself decides whether it should be null. So it needs access to this.

With an @case the surrounding Neos.Fusion:Switch decides whether something should be rendered so this should never be Vendor.Package:Thing .

@default and @case

The @default is used to streamline the current condition = true and, again, introduce common naming.

Using @case is not only more expressive (by keeping the same naming as other languages) it also does not work like @if. Multiple @if statements behave like @if.foo AND @if.bar. Multiple @case statements behave like @case.foo OR @case.bar. Just like “real” switches.

Example

Fusion and its pseudocode “real” switch counterpart

Neos.Fusion:Switch {
    foo = Neos.Fusion:Component {
        @case.value = ${val == "foo"}
        renderer = "is foo"
    }
    bar = Neos.Fusion:Component {
        @case.value = ${val == "bar"}
        renderer = "is bar"
    }
    @default = Neos.Fusion:Component {
        renderer = "is not foo and not bar"
    }
}
switch(val) {
  case "foo": return Neos\Fusion\Component("is foo")
  case "bar": return Neos\Fusion\Component("is bar")
  default: return Neos\Fusion\Component("is not foo and not bar")
}
1 Like

Thank you for the explanation.

+1 for the naming, I feel like “Switch” is something many people would indeed expect coming to a new language.

Good point with @case being an OR condition set.

However, I’m still a bit skeptical since both @if and @case “work”, but in different ways, or am I misunderstanding?

<Switch>
  <Example
    asset={props.asset1}
    @if={!!this.asset}
  />
  <Example
    asset={props.asset2}
    @case={!!this.asset}
  />
</Switch>

(Of course the example is a bit fabricated, but imagine an itemRenderer with a more involved property than passing a value 1:1, so you could pass it to the condition again. Idk)

Or do you intend to only render paths in the Switch with a @case or @default and if they don’t return it’s their decision (not sure if paths with unmet conditions are available to the fusion object to make this decision)?

How does a @default case work with ordering? Will something @positioned after the @default case never be rendered or will the @default case be pushed back automagically?

In general, I can only speak for the current Case where I have a lot of conditions referencing the local scope (e.g. flow-querying a node which I would later use again for rendering), but probably the new Switch comes with new possibilities and different use-cases :person_shrugging:

1 Like

I like the renaming and cleanup part.
@default should always be the last resort, and be excluded from the positionalArraySorter, like in the Neos.Fusion:Match implementation.

But having @if and @case is a problem I think. If we support both, the final behaviour seems to be unintuitive. So maybe only having @if and no new meta key would be easier, even though the new name would match other languages better.

Do we need the @path in AFX? Wouldn’t it just turn the cases into an indexed array which works the same? Of course the path would help with debugging.

yes. Because tag-content is usually passed to the content prop and:

<Neos.Fusion:Switch>
   <Neos.Fusion:Value @case.magic={...} />
   <Neos.Fusion:Renderer @case.magic={...} />
</Neos.Fusion:Switch>

would transpile to:

Neos.Fusion:Switch {
   content = Neos.Fusion:Join {
      1 = Neos.Fusion:Value {
          @case.magic = ${...}
      }
      2 = Neos.Fusion:Renderer {
          @case.magic = ${...}
      }
   }
}

Since i dislike @path quite a bit i would really love a way around that.

1 Like

Cool idea!

I don’t mind the @path part in AFX, as it makes clear what it is. Otherwise we could say, inside Switch you don’t have content but only the children. Or why wouldn’t this work?

Currently afx does always puts children into content, we would have to change this depending on the parent prototype. Not sure one would want that for consistency but there are some Fusion prototypes where this would make sense.

Yes exactly. It was never about not being able to tell afx for certain prototypes to break its rules, like we could make the current Case work as well, but that would result in some hacks and makes AFX more aware of specific Fusion prototypes (which it already kindof is) and possible harder to learn.

I would not be against that, but think that we should open up a new discussion for that, so we can put our focus here on the main idea of the Switch.

In a loop we adjusted the fusion prototype to accept content back then. This will not work here.

I agree this is not about afx but I would really like to have a clear idea to get it working in afx before adding a new switch prototype.

1 Like

@default

The @default is not affected by @position and will be evaluated at the end if no @case matched:

// POC: SwitchImplementation.php
// ...
    public function evaluate()
    {
        $this->assertPropertyCases();

        $sortedPropertyKeys = $this->preparePropertyKeys($this->properties, $this->ignoreProperties);
        if (count($sortedPropertyKeys) > 0) {
            foreach ($sortedPropertyKeys as $propertyKey) {
                if ($this->evaluateCases($propertyKey)) {
                    return $this->runtime->evaluate($this->path . '/' . $propertyKey);
                }
            }
        }

        return $this->getDefault();
    }
// ...

@if

I personally do not think @if will really lead to any kind of a new problem.

The usage of @if next to @case can be handled in the Documentation/Reference as a “should not be done”. BUT if someone wants to use a @if it will work as expected.

Here should be noted that a property inside the Neos.Fusion:Switch MUST have at least one @case otherwise an exception will be thrown (see $this->assertPropertyCases();). So when a single @if is used instead of a @case it wont render at all.

A @if somewhere inside the __prototypeChain might not be expected but that will always be a problem when the prototype is used anywhere. Not just inside of Neos.Fusion:Switch.
But here could the IDE do its part and (at least) warn the user (POC):

AFX

The whole idea of Neos.Fusion:Switch is the ease of use so it has to also translate into AFX.

But the AFX-Discussion might lead to bigger changes so i also think it should move into its own post.

Nevertheless my idea would be to implement a new meta property @directProperties. When set to true, all children will not be moved into the content property but each will be a direct property inside the parent.
This @directProperties property will be implicitly set to true inside of a Neos.Fusion:Switch. One could also think to name them case_X instead of item_X just to keep the naming consistent.

2 Likes