In Zcash Protocol Specification Version 2022.3.8 [NU5], chapter 8.3 Unification of Mints and Pours:

[Sapling onward] … This comes at the cost of revealing the exact number of shielded inputs and outputs, but dummy (zero-valued) outputs are still possible.

Are the numbers of shielded inputs and outputs in JoinSplit transfers hidden in the Pre-Sapling versions of Zcash? How is that possible? In my understanding, to spend shielded inputs or create shielded outputs, we have to disclose the related commitments or nullifiers, which makes public the numbers of shielded inputs and outputs. So how does JoinSplit actually handle these things?

What this quote is referring to is the fact that Sprout JoinSplits used a single proof for two spends and two outputs, which “hid” whether your transaction was 1-in 1-out, 1-in 2-out, 2-in 1-out, or 2-in 2-out. In practice however due to the way that JoinSplit chaining worked, each JoinSplit was really only 1-in 1-out. But that still meant that if your transaction had 5 JoinSplits, it could potentially be anywhere from 5-in 1-out to 1-in 5-out, and the network could not distinguish.

Sapling uses separate spend and output proofs, so it is possible to construct transactions that directly reveal the number of spends and outputs. Wallets pad to 1-in 2-out by default for a little arity-hiding, but the protocol doesn’t require it.

Orchard merged spend and output proofs back together for performance reasons, which means each Action corresponds to 1-in 1-out. It is therefore more similar to Sprout again, in that if you create a 1-in 5-out Orchard transaction, the network can’t distinguish it from a 5-in 1-out Orchard transaction.