STM32 SPI: strange behavior on empty TXFIFO (previous bytes history?)
I figured this out and decided to make this answer more comprehensive by creating some animations. First of all, there are 2 facts to keep in mind that determine the logic behind TXFIFO's behavior:


As the manual states, TXFIFO is of size 32-bit = 4 bytes - let those bytes be B1|B2|B3|B4 in that order.
FACT #1: When the master requests a byte from the slave, the popped and returned byte is B1 but it isn't actually removed from TXFIFO ---> the TXFIFO contents are left-rotated, and not left-shifted. This results in TXFIFO looking like B2|B3|B4|B1 after this pop, instead of B2|B3|B4|00 (which is I think what @ogrenci meant with his answer).
FACT #2: When TXFIFO becomes empty, for some reason a master request receives the 1st byte that is in TXFIFO @ that moment instead of 0x00 as I would have expected.


RXFIFO most probably behaves the same way.

The algorithm for both push/pop, written in C# is as following:

public class TXFIFO
{
    public byte[] data;
    byte push_position = 1;
    byte occupied = 0;

    public TXFIFO()
    {
        data = new byte[4];
    }

    public byte Push(byte v)
    {
        // write
        data[push_position - 1] = v;
        // push_position
        if (push_position &lt; 4) push_position++;
        else push_position = 1;
        // occupied
        if (occupied &lt; 4) occupied++;
        return v;
    }

    public byte Pop()
    {
        // read
        if (occupied == 0) return data[0];
        byte v = data[0];
        // rotate left once
        for (int i = 1; i &lt; 4; i++)
            data[i - 1] = data[i];
        data[3] = v;
        //push_position
        if (push_position &gt; 1) push_position--;
        else push_position = 4;
        //occupied
        if (occupied &gt; 0) occupied--;
        return v;
    }

    public byte GetOccupied()
    {
        return occupied;
    }
}


And here are 5 animations that illustrate the originally described ZEROS_CNT scenarios (see question's UPDATE 1). Note that, to make the point clearer, instead of inserting zeroes, I inserted 0x01-0x02-..to..-ZEROS_CNT values here.

ZEROS_CNT = 0:



ZEROS_CNT = 1:



ZEROS_CNT = 2:



ZEROS_CNT = 3:



ZEROS_CNT = 4:



ZEROS_CNT = 5:



...and so on...

As previously mentioned in the question, a workaround for having 0x00 sent when TXFIFO is empty would be keeping the SPI peripheral disabled when TXFIFO is empty until new data is written in DR, which is what I ended up doing, after understanding what is going on.